WebashalarForML commited on
Commit
4807519
·
verified ·
1 Parent(s): e78574e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +228 -221
app.py CHANGED
@@ -1,290 +1,297 @@
1
  #!/usr/bin/env python3
 
2
  import os
3
  import json
4
  import logging
5
  import re
6
- from typing import Dict, Any, List, Optional
7
  from pathlib import Path
 
 
8
  from flask import Flask, request, jsonify
9
  from flask_cors import CORS
10
  from dotenv import load_dotenv
11
- from werkzeug.utils import secure_filename
12
- from langchain_groq import ChatGroq
13
- from typing_extensions import TypedDict
14
-
15
- # --- Type Definitions for State Management ---
16
- class TaggedReply(TypedDict):
17
- reply: str
18
- tags: List[str]
19
-
20
- class AssistantState(TypedDict):
21
- conversationSummary: str
22
- language: str
23
- taggedReplies: List[TaggedReply]
24
- # Note: lastUserMessage is calculated on request, not stored in state
25
-
26
- # --- Logging ---
27
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
28
- logger = logging.getLogger("code-assistant")
29
 
30
- # --- Load environment ---
 
 
 
31
  load_dotenv()
32
  GROQ_API_KEY = os.getenv("GROQ_API_KEY")
33
  if not GROQ_API_KEY:
34
- logger.error("GROQ_API_KEY not set in environment")
35
- # For deployment, consider raising an exception instead of exiting:
36
- # raise ValueError("GROQ_API_KEY not set in environment")
37
- exit(1)
38
 
39
- # --- Flask app setup ---
40
- BASE_DIR = Path(__file__).resolve().parent
41
- static_folder = BASE_DIR / "static"
 
42
 
43
- app = Flask(__name__, static_folder=str(static_folder), static_url_path="/static")
44
- CORS(app)
 
45
 
46
- # --- LLM setup ---
47
- llm = ChatGroq(
48
- model=os.getenv("LLM_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct"),
49
- temperature=0.1, # Set a lower, deterministic temperature
50
- max_tokens=2048, # Ensure max_tokens is set to avoid truncation
51
- api_key=GROQ_API_KEY,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  )
53
 
54
- # --- Define the standard error message for failed parsing
55
- LLM_PARSE_ERROR_MESSAGE = "I'm sorry, I couldn't process the last response correctly due to a formatting issue. Could you please rephrase or try a simpler query?"
56
-
57
- PROGRAMMING_ASSISTANT_PROMPT = f"""
58
- You are an expert programming assistant. Your role is to provide code suggestions, fix bugs, explain programming concepts, and offer contextual help based on the user's query and preferred programming language.
59
 
60
- **CONTEXT HANDLING RULES (Follow these strictly):**
61
- - **Conversation Summary:** At the end of every response, you MUST provide an updated, concise `conversationSummary` based on the entire chat history provided. This summary helps you maintain context.
62
- - **Language Adaptation:** Adjust your suggestions, code, and explanations to the programming language specified in the 'language' field of the 'AssistantState'.
63
-
64
- STRICT OUTPUT FORMAT (JSON ONLY):
65
- Return a single JSON object with the following keys. **The JSON object MUST be enclosed in a single ```json block.**
66
- - assistant_reply: string // A natural language reply to the user (short and helpful). Do NOT include code blocks here.
67
- - code_snippet: string // If suggesting code, provide it here in a markdown code block. **CRITICALLY, you must escape all internal newlines as '\\n' and backslashes as '\\\\'** to keep the string value valid JSON. If no code is required, use an empty string: "".
68
- - state_updates: object // updates to the internal state, must include: language, conversationSummary
69
- - suggested_tags: array of strings // a list of 1-3 relevant tags for the assistant_reply
70
 
71
- Rules:
72
- - ALWAYS include all four top-level keys: `assistant_reply`, `code_snippet`, `state_updates`, and `suggested_tags`.
73
- - ALWAYS include `assistant_reply` as a non-empty string.
74
- - Do NOT produce any text outside the JSON block.
75
- """
76
 
77
- def extract_json_from_llm_response(raw_response: str) -> dict:
78
- # The default object is only used if parsing fails, providing a clean error message.
79
- # The actual state preservation logic is in the /chat route.
 
 
 
 
 
 
 
 
80
  default = {
81
- "assistant_reply": LLM_PARSE_ERROR_MESSAGE,
82
  "code_snippet": "",
83
- "state_updates": {"conversationSummary": "", "language": "Python"},
84
  "suggested_tags": [],
 
85
  }
86
-
87
- if not raw_response or not isinstance(raw_response, str):
88
  return default
89
-
90
- # 1. Use regex to find the JSON content inside the first code block (```json)
91
- m = re.search(r"```json\s*([\s\S]*?)\s*```", raw_response)
92
- json_string = m.group(1).strip() if m else raw_response
93
-
94
- # 2. Refine candidate to just the JSON object content
95
- first = json_string.find('{')
96
- last = json_string.rfind('}')
97
- candidate = json_string[first:last+1] if first != -1 and last != -1 and first < last else json_string
98
-
99
- # 3. Remove trailing commas which can break JSON parsing
100
- candidate = re.sub(r',\s*(?=[}\]])', '', candidate)
101
-
102
- try:
103
- parsed = json.loads(candidate)
104
- except Exception as e:
105
- logger.warning("Failed to parse JSON from LLM output: %s. Candidate: %s", e, candidate[:200]) # Truncate candidate for cleaner logs
106
- return default
107
-
108
- # 4. Validate and clean up the parsed dictionary
109
- if isinstance(parsed, dict) and "assistant_reply" in parsed:
110
  parsed.setdefault("code_snippet", "")
111
  parsed.setdefault("state_updates", {})
112
- parsed["state_updates"].setdefault("conversationSummary", "")
113
- parsed["state_updates"].setdefault("language", "Python")
114
  parsed.setdefault("suggested_tags", [])
115
-
116
- # Ensure reply is not empty
117
- if not parsed["assistant_reply"].strip():
118
- parsed["assistant_reply"] = "I need a clearer instruction to provide a reply."
119
-
120
  return parsed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  else:
122
- logger.warning("Parsed JSON missing required keys or invalid format. Returning default.")
123
- return default
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- def detect_language_from_text(text: str) -> Optional[str]:
126
- """Simple check for common programming languages."""
127
- if not text:
128
- return None
129
- lower = text.lower()
130
- known_languages = ["python", "javascript", "java", "c++", "c#", "go", "ruby", "php", "typescript", "swift"]
131
-
132
- lang_match = re.search(r'\b(in|using|for)\s+(' + '|'.join(known_languages) + r')\b', lower)
133
- if lang_match:
134
- return lang_match.group(2).capitalize()
135
- return None
136
 
137
- # --- Flask routes ---
138
  @app.route("/", methods=["GET"])
139
  def serve_frontend():
140
  try:
141
- return app.send_static_file("frontend.html")
142
  except Exception:
143
  return "<h3>frontend.html not found in static/ — please add your frontend.html there.</h3>", 404
144
 
145
  @app.route("/chat", methods=["POST"])
146
  def chat():
147
- data = request.get_json(force=True)
148
- if not isinstance(data, dict):
149
  return jsonify({"error": "invalid request body"}), 400
150
 
151
- chat_history: List[Dict[str, str]] = data.get("chat_history") or []
152
- assistant_state: AssistantState = data.get("assistant_state") or {}
153
 
154
- # Initialize state from input. This is the "safe" state.
155
- state: AssistantState = {
156
- "conversationSummary": assistant_state.get("conversationSummary", ""),
157
- "language": assistant_state.get("language", "Python"),
158
  "taggedReplies": assistant_state.get("taggedReplies", []),
159
  }
160
-
161
- # 1. Prepare LLM Messages from Full History (same as before)
162
- llm_messages = [{"role": "system", "content": PROGRAMMING_ASSISTANT_PROMPT}]
163
-
164
- last_user_message = ""
165
- for msg in chat_history:
166
- role = msg.get("role")
167
- content = msg.get("content")
168
- if role in ["user", "assistant"] and content:
169
- llm_messages.append({"role": role, "content": content})
170
- if role == "user":
171
- last_user_message = content
172
-
173
- # 2. Language Detection & State Update (same as before)
174
- detected_lang = detect_language_from_text(last_user_message)
175
- if detected_lang and detected_lang.lower() != state["language"].lower():
176
- logger.info("Detected new language: %s", detected_lang)
177
- state["language"] = detected_lang
178
-
179
- # 3. Inject Contextual Hint and State into the LAST user message (same as before)
180
- context_hint = f"Current Language: {state['language']}. Conversation Summary so far: {state['conversationSummary']}"
181
- if llm_messages and llm_messages[-1]["role"] == "user":
182
- llm_messages[-1]["content"] = f"USER MESSAGE: {last_user_message}\n\n[CONTEXT HINT: {context_hint}]"
183
- elif last_user_message:
184
- llm_messages.append({"role": "user", "content": f"USER MESSAGE: {last_user_message}\n\n[CONTEXT HINT: {context_hint}]"})
185
-
186
-
187
- try:
188
- logger.info("Invoking LLM with full history and prepared prompt...")
189
- llm_response = llm.invoke(llm_messages)
190
- raw_response = llm_response.content if hasattr(llm_response, "content") else str(llm_response)
191
-
192
- logger.info(f"Raw LLM response: {raw_response}")
193
- parsed_result = extract_json_from_llm_response(raw_response)
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  except Exception as e:
196
- logger.exception("LLM invocation failed")
197
- error_detail = str(e)
198
- if 'decommissioned' in error_detail:
199
- error_detail = "LLM Model Error: The model is likely decommissioned. Please check the 'LLM_MODEL' environment variable or the default model in app.py."
200
- return jsonify({"error": "LLM invocation failed", "detail": error_detail}), 500
201
-
202
- # 4. State Update from LLM (NEW ROBUST LOGIC)
203
-
204
- # Check if parsing failed (by comparing the reply to the known error message)
205
- if parsed_result.get("assistant_reply") == LLM_PARSE_ERROR_MESSAGE:
206
- final_reply_content = LLM_PARSE_ERROR_MESSAGE
207
-
208
- # State and tags remain as initialized (from the input assistant_state), fulfilling the user request.
209
- response_payload = {
210
- "assistant_reply": final_reply_content,
211
- "updated_state": state, # Keep the original input state
212
  "suggested_tags": [],
213
- }
214
- return jsonify(response_payload)
215
-
216
- # Parsing was successful. Safely update the state.
217
- updated_state_from_llm = parsed_result.get("state_updates", {})
218
-
219
- if 'conversationSummary' in updated_state_from_llm:
220
- state["conversationSummary"] = updated_state_from_llm["conversationSummary"]
221
- # We allow the language to be updated only if it's explicitly set by the LLM AND it's a valid change.
222
- if 'language' in updated_state_from_llm and updated_state_from_llm['language'].strip():
223
- state["language"] = updated_state_from_llm["language"]
224
-
225
-
226
- assistant_reply = parsed_result.get("assistant_reply")
227
- code_snippet = parsed_result.get("code_snippet")
228
-
229
- # 5. Final Response Payload: Combine the reply and the code snippet
230
- final_reply_content = assistant_reply
231
- if code_snippet and code_snippet.strip():
232
- if final_reply_content.strip():
233
- final_reply_content += "\n\n"
234
- final_reply_content += code_snippet
235
-
236
- if not final_reply_content.strip():
237
- final_reply_content = "I'm here to help with your code! What programming language are you using?"
238
-
239
- response_payload = {
240
- "assistant_reply": final_reply_content,
241
- "updated_state": state,
242
- "suggested_tags": parsed_result.get("suggested_tags", []),
243
- }
244
 
245
- return jsonify(response_payload)
 
 
 
 
 
 
246
 
247
  @app.route("/tag_reply", methods=["POST"])
248
  def tag_reply():
249
- data = request.get_json(force=True)
250
  if not isinstance(data, dict):
251
  return jsonify({"error": "invalid request body"}), 400
252
 
253
  reply_content = data.get("reply")
254
- tags = data.get("tags")
255
- assistant_state: AssistantState = data.get("assistant_state") or {}
256
-
257
- if not reply_content or not tags:
258
  return jsonify({"error": "Missing 'reply' or 'tags' in request"}), 400
259
-
260
- tags = [str(t).strip() for t in tags if str(t).strip()]
261
- if not tags:
262
- return jsonify({"error": "Tags list cannot be empty"}), 400
263
 
264
- state: AssistantState = {
 
 
 
 
 
265
  "conversationSummary": assistant_state.get("conversationSummary", ""),
266
- "language": assistant_state.get("language", "Python"),
267
  "taggedReplies": assistant_state.get("taggedReplies", []),
268
  }
269
 
270
- new_tagged_reply: TaggedReply = {
271
- "reply": reply_content,
272
- "tags": tags,
273
- }
274
-
275
- state["taggedReplies"].append(new_tagged_reply)
276
-
277
- logger.info("Reply tagged with: %s", tags)
278
-
279
- return jsonify({
280
- "message": "Reply saved and tagged successfully.",
281
- "updated_state": state,
282
- }), 200
283
 
284
  @app.route("/ping", methods=["GET"])
285
  def ping():
286
  return jsonify({"status": "ok"})
287
 
288
  if __name__ == "__main__":
289
- port = int(os.getenv("PORT", 7860))
290
- app.run(host="0.0.0.0", port=port, debug=True)
 
1
  #!/usr/bin/env python3
2
+ # filename: app_refactored.py
3
  import os
4
  import json
5
  import logging
6
  import re
 
7
  from pathlib import Path
8
+ from typing import Dict, Any, List, Optional, Tuple
9
+
10
  from flask import Flask, request, jsonify
11
  from flask_cors import CORS
12
  from dotenv import load_dotenv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ # Replace with your LLM client import; kept generic here.
15
+ # from langchain_groq import ChatGroq
16
+
17
+ # === Config ===
18
  load_dotenv()
19
  GROQ_API_KEY = os.getenv("GROQ_API_KEY")
20
  if not GROQ_API_KEY:
21
+ raise RuntimeError("GROQ_API_KEY not set in environment")
 
 
 
22
 
23
+ LLM_MODEL = os.getenv("LLM_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct")
24
+ LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "20"))
25
+ MAX_HISTORY_MESSAGES = int(os.getenv("MAX_HISTORY_MESSAGES", "12"))
26
+ VALID_LANGUAGES = {"python", "javascript", "java", "c++", "c#", "go", "ruby", "php", "typescript", "swift"}
27
 
28
+ # === Logging ===
29
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
30
+ logger = logging.getLogger("code-assistant")
31
 
32
+ # === LLM client (example) ===
33
+ # NOTE: adapt this block to match your SDK. Keep a tolerant accessor for response text.
34
+ class DummyLLM:
35
+ def __init__(self, **kwargs):
36
+ self.kwargs = kwargs
37
+ def invoke(self, messages: List[Dict[str, str]], timeout: Optional[float] = None):
38
+ # stub: replace with real client call
39
+ class Resp: pass
40
+ r = Resp()
41
+ r.content = json.dumps({
42
+ "assistant_reply": "This is a dummy reply. Replace with real LLM client.",
43
+ "code_snippet": "",
44
+ "state_updates": {"conversationSummary": "dummy", "language": "Python"},
45
+ "suggested_tags": ["example"]
46
+ })
47
+ return r
48
+
49
+ # llm = ChatGroq(model=LLM_MODEL, api_key=GROQ_API_KEY, temperature=0.1, max_tokens=2048)
50
+ llm = DummyLLM(model=LLM_MODEL, api_key=GROQ_API_KEY) # replace with real client
51
+
52
+ # === Prompt ===
53
+ SYSTEM_PROMPT = (
54
+ "You are an expert programming assistant. Prefer to return a JSON object with keys: "
55
+ "assistant_reply (string), code_snippet (string, optional, can be multiline), "
56
+ "state_updates (object), suggested_tags (array). If including code, put it in triple backticks. "
57
+ "Do NOT escape newlines in code_snippet; return natural multi-line strings."
58
  )
59
 
60
+ # === Utilities ===
61
+ def clamp_summary(s: str, max_len: int = 1200) -> str:
62
+ s = (s or "").strip()
63
+ return s if len(s) <= max_len else s[:max_len-3] + "..."
 
64
 
65
+ def canonicalize_language(text: Optional[str]) -> Optional[str]:
66
+ if not text:
67
+ return None
68
+ t = text.strip().lower()
69
+ # quick membership test
70
+ for lang in VALID_LANGUAGES:
71
+ if lang in t or t == lang:
72
+ return lang
73
+ return None
 
74
 
75
+ def try_parse_json(s: str) -> Optional[Dict[str, Any]]:
76
+ try:
77
+ return json.loads(s)
78
+ except Exception:
79
+ return None
80
 
81
+ def extract_code_fence(text: str) -> Optional[str]:
82
+ m = re.search(r"```(?:[a-zA-Z0-9_+\-]*)\n([\s\S]*?)```", text)
83
+ return m.group(1).strip() if m else None
84
+
85
+ def parse_llm_output(raw: str) -> Dict[str, Any]:
86
+ """
87
+ Tolerant multi-strategy parser:
88
+ 1) Direct JSON
89
+ 2) JSON inside a ```json``` fence
90
+ 3) Heuristic extraction: assistant_reply lines, code fences for code_snippet, simple state_updates line (json)
91
+ """
92
  default = {
93
+ "assistant_reply": "I couldn't parse the model response. Please rephrase or simplify the request.",
94
  "code_snippet": "",
95
+ "state_updates": {"conversationSummary": "", "language": "python"},
96
  "suggested_tags": [],
97
+ "parse_ok": False,
98
  }
99
+ if not raw or not isinstance(raw, str):
 
100
  return default
101
+
102
+ raw = raw.strip()
103
+
104
+ # 1) direct JSON
105
+ parsed = try_parse_json(raw)
106
+ if parsed and isinstance(parsed, dict) and "assistant_reply" in parsed:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  parsed.setdefault("code_snippet", "")
108
  parsed.setdefault("state_updates", {})
 
 
109
  parsed.setdefault("suggested_tags", [])
110
+ parsed["parse_ok"] = True
 
 
 
 
111
  return parsed
112
+
113
+ # 2) JSON inside any code fence (```json ... ```)
114
+ m_json_fence = re.search(r"```json\s*([\s\S]*?)```", raw, re.IGNORECASE)
115
+ if m_json_fence:
116
+ candidate = m_json_fence.group(1)
117
+ parsed = try_parse_json(candidate)
118
+ if parsed and "assistant_reply" in parsed:
119
+ parsed.setdefault("code_snippet", "")
120
+ parsed.setdefault("state_updates", {})
121
+ parsed.setdefault("suggested_tags", [])
122
+ parsed["parse_ok"] = True
123
+ return parsed
124
+
125
+ # 3) Heuristics: find assistant_reply: ...; code fence for code; state_updates as inline JSON
126
+ assistant_reply = ""
127
+ code_snippet = ""
128
+ state_updates = {}
129
+ suggested_tags = []
130
+
131
+ # a) extract code fence (first code block)
132
+ code_snippet = extract_code_fence(raw) or ""
133
+
134
+ # b) extract assistant_reply by looking for lines like "assistant_reply:" or markdown bold
135
+ m = re.search(r'assistant_reply\s*[:\-]\s*(["\']?)([\s\S]*?)(?=\n[a-z_]+[\s\-:]{1}|$)', raw, re.IGNORECASE)
136
+ if m:
137
+ assistant_reply = m.group(2).strip()
138
  else:
139
+ # fallback: take everything up to the first code fence or up to "state_updates"
140
+ cut_idx = raw.find("```")
141
+ state_idx = raw.lower().find("state_updates")
142
+ end = min([i for i in (cut_idx if cut_idx>=0 else len(raw), state_idx if state_idx>=0 else len(raw))])
143
+ assistant_reply = raw[:end].strip()
144
+ # strip any leading labels like "**assistant_reply**:" or similar
145
+ assistant_reply = re.sub(r'^\**\s*assistant_reply\**\s*[:\-]?\s*', '', assistant_reply, flags=re.IGNORECASE).strip()
146
+
147
+ # c) find state_updates JSON block if present
148
+ m_state = re.search(r"state_updates\s*[:\-]?\s*(\{[\s\S]*?\})", raw, re.IGNORECASE)
149
+ if m_state:
150
+ try:
151
+ state_updates = json.loads(m_state.group(1))
152
+ except Exception:
153
+ state_updates = {}
154
+
155
+ # d) suggested_tags simple extract
156
+ m_tags = re.search(r"suggested_tags\s*[:\-]?\s*(\[[^\]]*\])", raw, re.IGNORECASE)
157
+ if m_tags:
158
+ try:
159
+ suggested_tags = json.loads(m_tags.group(1))
160
+ except Exception:
161
+ suggested_tags = []
162
+
163
+ result = {
164
+ "assistant_reply": assistant_reply or default["assistant_reply"],
165
+ "code_snippet": code_snippet or "",
166
+ "state_updates": state_updates or {"conversationSummary": "", "language": "python"},
167
+ "suggested_tags": suggested_tags or [],
168
+ "parse_ok": bool(assistant_reply or code_snippet),
169
+ }
170
+ return result
171
 
172
+ # === Flask app ===
173
+ BASE_DIR = Path(__file__).resolve().parent
174
+ app = Flask(__name__, static_folder=str(BASE_DIR / "static"), static_url_path="/static")
175
+ CORS(app)
 
 
 
 
 
 
 
176
 
 
177
  @app.route("/", methods=["GET"])
178
  def serve_frontend():
179
  try:
180
+ return app.send_static_file("frontend.html")
181
  except Exception:
182
  return "<h3>frontend.html not found in static/ — please add your frontend.html there.</h3>", 404
183
 
184
  @app.route("/chat", methods=["POST"])
185
  def chat():
186
+ payload = request.get_json(force=True, silent=True)
187
+ if not isinstance(payload, dict):
188
  return jsonify({"error": "invalid request body"}), 400
189
 
190
+ chat_history = payload.get("chat_history", [])
191
+ assistant_state = payload.get("assistant_state", {})
192
 
193
+ # validate/normalize assistant_state
194
+ state = {
195
+ "conversationSummary": assistant_state.get("conversationSummary", "").strip(),
196
+ "language": assistant_state.get("language", "python").strip().lower(),
197
  "taggedReplies": assistant_state.get("taggedReplies", []),
198
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
+ # limit history length to recent messages to control token usage
201
+ if isinstance(chat_history, list) and len(chat_history) > MAX_HISTORY_MESSAGES:
202
+ chat_history = chat_history[-MAX_HISTORY_MESSAGES:]
203
+
204
+ # build messages for LLM (do not mutate user's last message)
205
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
206
+ for m in chat_history:
207
+ if not isinstance(m, dict):
208
+ continue
209
+ role = m.get("role")
210
+ content = m.get("content")
211
+ if role in ("user", "assistant") and content:
212
+ messages.append({"role": role, "content": content})
213
+
214
+ # append a supplemental context message (do not overwrite)
215
+ context_hint = f"[CONTEXT] language={state['language']} summary={clamp_summary(state['conversationSummary'], 300)}"
216
+ messages.append({"role": "system", "content": context_hint})
217
+
218
+ # call LLM (wrap in try/except)
219
+ try:
220
+ raw_resp = llm.invoke(messages, timeout=LLM_TIMEOUT_SECONDS)
221
+ # tolerate different shapes
222
+ raw_text = getattr(raw_resp, "content", None) or getattr(raw_resp, "text", None) or str(raw_resp)
223
+ logger.info("LLM raw text: %.300s", raw_text.replace('\n', ' ')[:300])
224
  except Exception as e:
225
+ logger.exception("LLM invocation error")
226
+ return jsonify({"error": "LLM invocation failed", "detail": str(e)}), 500
227
+
228
+ parsed = parse_llm_output(raw_text)
229
+
230
+ # If parse failed, don't overwrite the existing state; give helpful message.
231
+ if not parsed.get("parse_ok"):
232
+ logger.warning("Parse failure. Returning fallback message.")
233
+ return jsonify({
234
+ "assistant_reply": parsed["assistant_reply"],
235
+ "code_snippet": "",
236
+ "updated_state": state,
 
 
 
 
237
  "suggested_tags": [],
238
+ "parse_ok": False,
239
+ }), 200
240
+
241
+ # Validate and apply state_updates conservatively
242
+ updates = parsed.get("state_updates", {}) or {}
243
+ if isinstance(updates, dict):
244
+ if "conversationSummary" in updates:
245
+ state["conversationSummary"] = clamp_summary(str(updates["conversationSummary"]))
246
+ if "language" in updates:
247
+ lang = canonicalize_language(str(updates["language"]))
248
+ if lang:
249
+ state["language"] = lang
250
+
251
+ # limit suggested tags
252
+ tags = parsed.get("suggested_tags", []) or []
253
+ if isinstance(tags, list):
254
+ tags = [str(t).strip() for t in tags if t and isinstance(t, (str,))]
255
+ tags = tags[:3]
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
+ return jsonify({
258
+ "assistant_reply": parsed.get("assistant_reply", ""),
259
+ "code_snippet": parsed.get("code_snippet", ""),
260
+ "updated_state": state,
261
+ "suggested_tags": tags,
262
+ "parse_ok": True,
263
+ }), 200
264
 
265
  @app.route("/tag_reply", methods=["POST"])
266
  def tag_reply():
267
+ data = request.get_json(force=True, silent=True)
268
  if not isinstance(data, dict):
269
  return jsonify({"error": "invalid request body"}), 400
270
 
271
  reply_content = data.get("reply")
272
+ tags = data.get("tags", [])
273
+ if not reply_content or not tags or not isinstance(tags, list):
 
 
274
  return jsonify({"error": "Missing 'reply' or 'tags' in request"}), 400
 
 
 
 
275
 
276
+ tags_clean = [str(t).strip().lower() for t in tags if re.match(r'^[\w\-]{1,30}$', str(t).strip())]
277
+ if not tags_clean:
278
+ return jsonify({"error": "No valid tags provided"}), 400
279
+
280
+ assistant_state = data.get("assistant_state", {})
281
+ state = {
282
  "conversationSummary": assistant_state.get("conversationSummary", ""),
283
+ "language": assistant_state.get("language", "python"),
284
  "taggedReplies": assistant_state.get("taggedReplies", []),
285
  }
286
 
287
+ state["taggedReplies"].append({"reply": reply_content, "tags": tags_clean})
288
+ logger.info("Tagged reply saved: %s", tags_clean)
289
+ return jsonify({"message": "Reply saved", "updated_state": state}), 200
 
 
 
 
 
 
 
 
 
 
290
 
291
  @app.route("/ping", methods=["GET"])
292
  def ping():
293
  return jsonify({"status": "ok"})
294
 
295
  if __name__ == "__main__":
296
+ port = int(os.getenv("PORT", "7860"))
297
+ app.run(host="0.0.0.0", port=port, debug=True)