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+ )
2838from app .services .indicator_workspace import is_indicator_ide_listable
2939from 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
687758def 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():
864944Return **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