Skip to content

Commit 45eb935

Browse files
HenryHenry
authored andcommitted
feat: add indicator AI workspace memory
1 parent 04df335 commit 45eb935

5 files changed

Lines changed: 921 additions & 6 deletions

File tree

backend_api_python/app/routes/indicator.py

Lines changed: 249 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525
INDICATOR_GENERATION_CONTRACT,
2626
INDICATOR_REPAIR_REQUIREMENTS,
2727
)
28+
from app.services.ai_copilot_context import fit_messages_to_budget
29+
from app.services.indicator_ai_workspace import (
30+
begin_turn as begin_indicator_ai_turn,
31+
classify_indicator_ai_intent,
32+
clear_workspace as clear_indicator_ai_workspace,
33+
complete_discussion_turn as complete_indicator_ai_discussion_turn,
34+
complete_turn as complete_indicator_ai_turn,
35+
get_workspace as get_indicator_ai_workspace,
36+
set_change_status as set_indicator_ai_change_status,
37+
)
2838
from app.services.indicator_workspace import is_indicator_ide_listable
2939
from app.services.indicator_versions import (
3040
get_version as get_indicator_code_version,
@@ -154,7 +164,17 @@ def _indicator_ai_text(key: str, lang: str = "zh-CN") -> str:
154164
texts = {
155165
"prompt_required": "Prompt cannot be empty",
156166
"insufficient_credits": "Insufficient credits. Please top up and try again.",
167+
"candidate_ready": "A candidate version is ready and has passed automatic code checks. Preview it before applying it to the editor.",
168+
"candidate_needs_review": "A candidate is ready, but automatic checks found issues. Review the validation result before applying it.",
157169
}
170+
if _is_zh_lang(lang):
171+
zh_texts = {
172+
"prompt_required": "请输入指标修改需求",
173+
"insufficient_credits": "积分不足,请充值后重试。",
174+
"candidate_ready": "候选版本已生成并通过自动代码检查。请先预览,再决定是否应用到编辑器。",
175+
"candidate_needs_review": "候选版本已生成,但自动检查发现问题。请先查看检查结果,不要直接应用。",
176+
}
177+
return zh_texts.get(key, texts.get(key, key))
158178
return texts.get(key, key)
159179

160180

@@ -682,6 +702,57 @@ def preview_indicator_chart():
682702
return jsonify({"code": 0, "msg": str(exc), "data": None}), 500
683703

684704

705+
@indicator_blp.route("/aiWorkspace/<int:indicator_id>", methods=["GET"])
706+
@login_required
707+
def indicator_ai_workspace(indicator_id: int):
708+
"""Load the bounded AI authoring workspace for one owned indicator."""
709+
try:
710+
data = get_indicator_ai_workspace(g.user_id, indicator_id)
711+
return jsonify({"code": 1, "msg": "success", "data": data})
712+
except LookupError as exc:
713+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 404
714+
except PermissionError as exc:
715+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 403
716+
except Exception as exc:
717+
logger.error("indicator_ai_workspace failed: %s", exc, exc_info=True)
718+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 500
719+
720+
721+
@indicator_blp.route("/aiWorkspace/<int:indicator_id>", methods=["DELETE"])
722+
@login_required
723+
def delete_indicator_ai_workspace(indicator_id: int):
724+
"""Clear conversation and pending candidates without touching saved code."""
725+
try:
726+
data = clear_indicator_ai_workspace(g.user_id, indicator_id)
727+
return jsonify({"code": 1, "msg": "success", "data": data})
728+
except LookupError as exc:
729+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 404
730+
except PermissionError as exc:
731+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 403
732+
except Exception as exc:
733+
logger.error("delete_indicator_ai_workspace failed: %s", exc, exc_info=True)
734+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 500
735+
736+
737+
@indicator_blp.route("/aiWorkspace/changes/<int:change_id>/status", methods=["POST"])
738+
@login_required
739+
def update_indicator_ai_change_status(change_id: int):
740+
"""Mark a generated candidate as applied or discarded."""
741+
try:
742+
status = str((request.get_json() or {}).get("status") or "").strip().lower()
743+
data = set_indicator_ai_change_status(g.user_id, change_id, status)
744+
return jsonify({"code": 1, "msg": "success", "data": data})
745+
except ValueError as exc:
746+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 400
747+
except LookupError as exc:
748+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 404
749+
except PermissionError as exc:
750+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 403
751+
except Exception as exc:
752+
logger.error("update_indicator_ai_change_status failed: %s", exc, exc_info=True)
753+
return jsonify({"code": 0, "msg": str(exc), "data": None}), 500
754+
755+
685756
@indicator_blp.route("/aiGenerate", methods=["POST"])
686757
@login_required
687758
def ai_generate():
@@ -700,6 +771,15 @@ def ai_generate():
700771
prompt = (data.get("prompt") or "").strip()
701772
existing = (data.get("existingCode") or "").strip()
702773
context = data.get("context") if isinstance(data.get("context"), dict) else {}
774+
source = str(data.get("source") or context.get("source") or "").strip()
775+
indicator_id = context.get("indicatorId")
776+
requested_interaction_mode = str(data.get("interactionMode") or "auto").strip().lower()
777+
resolved_interaction_mode = (
778+
classify_indicator_ai_intent(prompt, requested_interaction_mode)
779+
if source == "indicator_ide"
780+
else "modify"
781+
)
782+
workspace_context: Dict[str, Any] | None = None
703783

704784
if not prompt:
705785
# Keep SSE contract (match PHP behavior) so frontend doesn't look "stuck".
@@ -864,6 +944,88 @@ def _err_stream():
864944
Return **only** valid Python source: **no** markdown fences, **no** ` ``` `, **no** explanation before or after the code. First non-empty line should be `my_indicator_name` or `# @param` immediately followed by `my_indicator_name`.
865945
""" + "\n\n" + INDICATOR_GENERATION_CONTRACT
866946

947+
def _discussion_fallback() -> str:
948+
indicator_name = str(context.get("indicatorName") or "").strip() or "this indicator"
949+
param_names = re.findall(r"^\s*#\s*@param\s+([A-Za-z_]\w*)", existing, flags=re.MULTILINE)
950+
output_parts: List[str] = []
951+
if re.search(r"['\"]plots['\"]\s*:", existing):
952+
output_parts.append("plots")
953+
if re.search(r"['\"]signals['\"]\s*:", existing):
954+
output_parts.append("signals")
955+
if re.search(r"['\"]layers['\"]\s*:", existing):
956+
output_parts.append("layers")
957+
if _is_zh_lang(lang):
958+
params_text = "、".join(param_names[:8]) if param_names else "未声明可调参数"
959+
outputs_text = "、".join(output_parts) if output_parts else "尚未识别到标准输出结构"
960+
return (
961+
f"当前指标是「{indicator_name}」。代码声明的参数包括:{params_text};"
962+
f"输出结构包括:{outputs_text}。你可以继续问具体变量、信号条件或图表含义。"
963+
"这次仅回答问题,没有生成或修改代码。"
964+
)
965+
params_text = ", ".join(param_names[:8]) if param_names else "no declared tunable parameters"
966+
outputs_text = ", ".join(output_parts) if output_parts else "no recognized standard output structure"
967+
return (
968+
f"The current indicator is {indicator_name}. Declared parameters: {params_text}. "
969+
f"Recognized outputs: {outputs_text}. You can ask about a variable, signal condition, or chart element. "
970+
"This answer did not generate or modify code."
971+
)
972+
973+
def _generate_discussion_via_llm() -> str:
974+
"""Answer a code question without returning replacement source."""
975+
from app.services.llm import LLMService
976+
977+
llm = LLMService()
978+
if not llm.get_api_key():
979+
return _discussion_fallback()
980+
981+
discussion_system = """You are QuantDinger's indicator-code reviewer.
982+
Answer the user's question about the currently open indicator. Use the same language as the user.
983+
Explain concrete logic, parameters, plots, visual signals, edge cases, and limitations from the supplied source.
984+
Never claim that code was changed. Do not return a full replacement script and do not create a code candidate.
985+
When useful, cite short variable or function names from the source, but keep the answer concise and readable.
986+
Lead with the conclusion. By default use 4-8 short points and stay under 700 Chinese characters or 350 English words; only go deeper when the user explicitly asks for a detailed walkthrough.
987+
If the question actually requests a code modification, explain what should change and ask the user to state the desired change explicitly; do not write the replacement code in this discussion response."""
988+
messages: List[Dict[str, str]] = [{"role": "system", "content": discussion_system}]
989+
if workspace_context:
990+
summary_text = json.dumps(workspace_context.get("summary") or {}, ensure_ascii=False, default=str)
991+
messages.append({
992+
"role": "system",
993+
"content": "# Bounded indicator conversation memory\n" + summary_text[:4000],
994+
})
995+
for item in workspace_context.get("recent_messages") or []:
996+
role = str(item.get("role") or "")
997+
content_text = str(item.get("content") or "").strip()
998+
if role in {"user", "assistant"} and content_text:
999+
messages.append({"role": role, "content": content_text[:2000]})
1000+
1001+
chart_context = {
1002+
"market": context.get("market"),
1003+
"symbol": context.get("symbol"),
1004+
"timeframe": context.get("timeframe"),
1005+
"indicator_name": context.get("indicatorName"),
1006+
"indicator_description": context.get("indicatorDescription"),
1007+
}
1008+
messages.append({
1009+
"role": "user",
1010+
"content": (
1011+
"# Current chart context\n"
1012+
+ json.dumps(chart_context, ensure_ascii=False, default=str)
1013+
+ "\n\n# Current indicator source (source of truth)\n```python\n"
1014+
+ existing[:36000]
1015+
+ "\n```\n\n# User question\n"
1016+
+ prompt
1017+
),
1018+
})
1019+
messages, budget_debug = fit_messages_to_budget(messages, max_tokens=32000)
1020+
logger.info("indicator discussion context budget=%s", _sse_json(budget_debug))
1021+
answer = llm.call_llm_api(
1022+
messages=messages,
1023+
model=llm.get_default_model(),
1024+
temperature=0.25,
1025+
use_json_mode=False,
1026+
)
1027+
return str(answer or "").strip() or _discussion_fallback()
1028+
8671029
def _template_code() -> str:
8681030
from app.services.indicator_default_template import build_default_indicator_template
8691031

@@ -946,11 +1108,30 @@ def _context_block() -> str:
9461108

9471109
# Call LLM using the unified API (auto-selects provider based on LLM_PROVIDER env)
9481110
# use_json_mode=False because we want raw Python code output
1111+
messages: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]
1112+
if workspace_context:
1113+
summary_text = json.dumps(workspace_context.get("summary") or {}, ensure_ascii=False, default=str)
1114+
messages.append({
1115+
"role": "system",
1116+
"content": (
1117+
"# Indicator authoring memory\n"
1118+
"Use this bounded memory only to preserve the user's intent and prior constraints. "
1119+
"The current code below is always the source of truth.\n" + summary_text[:5000]
1120+
),
1121+
})
1122+
for item in workspace_context.get("recent_messages") or []:
1123+
role = str(item.get("role") or "")
1124+
if role not in {"user", "assistant"}:
1125+
continue
1126+
content_text = str(item.get("content") or "").strip()
1127+
if content_text:
1128+
messages.append({"role": role, "content": content_text[:2400]})
1129+
messages.append({"role": "user", "content": user_prompt})
1130+
messages, budget_debug = fit_messages_to_budget(messages, max_tokens=48000)
1131+
logger.info("indicator ai context budget=%s", _sse_json(budget_debug))
1132+
9491133
content = llm.call_llm_api(
950-
messages=[
951-
{"role": "system", "content": SYSTEM_PROMPT},
952-
{"role": "user", "content": user_prompt},
953-
],
1134+
messages=messages,
9541135
model=current_model,
9551136
temperature=temperature,
9561137
use_json_mode=False # Code generation doesn't need JSON mode
@@ -1151,21 +1332,83 @@ def _generate_final_code() -> tuple[str, Dict[str, Any]]:
11511332
# Capture user_id before generator runs (generator executes outside request context)
11521333
user_id = g.user_id
11531334
def stream():
1335+
nonlocal workspace_context
11541336
from app.services.billing_service import get_billing_service
11551337
billing = get_billing_service()
1338+
billing_feature = "ai_copilot_chat" if resolved_interaction_mode == "discussion" else "ai_code_gen"
11561339
ok, msg = billing.check_and_consume(
11571340
user_id=user_id,
1158-
feature='ai_code_gen',
1159-
reference_id=f"ai_code_gen_{user_id}_{int(time.time())}"
1341+
feature=billing_feature,
1342+
reference_id=f"{billing_feature}_{user_id}_{int(time.time())}"
11601343
)
11611344
if not ok:
11621345
error_msg = f"Insufficient credits: {msg}" if msg else _indicator_ai_text("insufficient_credits", lang)
11631346
yield "data: " + _sse_json({"error": error_msg}) + "\n\n"
11641347
yield "data: [DONE]\n\n"
11651348
return
11661349

1350+
if source == "indicator_ide" and indicator_id not in (None, ""):
1351+
try:
1352+
workspace_context = begin_indicator_ai_turn(
1353+
user_id,
1354+
int(indicator_id),
1355+
prompt,
1356+
intent=resolved_interaction_mode,
1357+
)
1358+
except (LookupError, PermissionError, ValueError) as exc:
1359+
yield "data: " + _sse_json({"error": str(exc)}) + "\n\n"
1360+
yield "data: [DONE]\n\n"
1361+
return
1362+
except Exception as exc:
1363+
logger.error("begin indicator AI turn failed: %s", exc, exc_info=True)
1364+
yield "data: " + _sse_json({"error": "indicator_ai_workspace_unavailable"}) + "\n\n"
1365+
yield "data: [DONE]\n\n"
1366+
return
1367+
1368+
if workspace_context and resolved_interaction_mode == "discussion":
1369+
try:
1370+
discussion_text = _generate_discussion_via_llm()
1371+
workspace_result = complete_indicator_ai_discussion_turn(
1372+
user_id=user_id,
1373+
workspace=workspace_context,
1374+
answer=discussion_text,
1375+
)
1376+
yield "data: " + _sse_json({"workspace": workspace_result}) + "\n\n"
1377+
chunk_size = 240
1378+
for i in range(0, len(discussion_text), chunk_size):
1379+
yield "data: " + _sse_json({"content": discussion_text[i : i + chunk_size]}) + "\n\n"
1380+
yield "data: [DONE]\n\n"
1381+
return
1382+
except Exception as exc:
1383+
logger.error("indicator AI discussion failed: %s", exc, exc_info=True)
1384+
yield "data: " + _sse_json({"error": "indicator_ai_discussion_failed"}) + "\n\n"
1385+
yield "data: [DONE]\n\n"
1386+
return
1387+
11671388
code_text, debug_info = _generate_final_code()
11681389

1390+
if workspace_context:
1391+
validation = _validate_indicator_code_internal(code_text)
1392+
assistant_text = _indicator_ai_text("candidate_ready", lang)
1393+
if not validation.get("success"):
1394+
assistant_text = _indicator_ai_text("candidate_needs_review", lang)
1395+
try:
1396+
workspace_result = complete_indicator_ai_turn(
1397+
user_id=user_id,
1398+
workspace=workspace_context,
1399+
prompt=prompt,
1400+
base_code=existing,
1401+
candidate_code=code_text,
1402+
validation=validation,
1403+
assistant_text=assistant_text,
1404+
)
1405+
yield "data: " + _sse_json({"workspace": workspace_result}) + "\n\n"
1406+
except Exception as exc:
1407+
logger.error("complete indicator AI turn failed: %s", exc, exc_info=True)
1408+
yield "data: " + _sse_json({"error": "indicator_ai_workspace_save_failed"}) + "\n\n"
1409+
yield "data: [DONE]\n\n"
1410+
return
1411+
11691412
yield "data: " + _sse_json({"debug": debug_info}) + "\n\n"
11701413

11711414
# Stream in chunks (front-end appends).

0 commit comments

Comments
 (0)