Update app.py
Browse files
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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 31 |
load_dotenv()
|
| 32 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
| 33 |
if not GROQ_API_KEY:
|
| 34 |
-
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
)
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
- suggested_tags: array of strings // a list of 1-3 relevant tags for the assistant_reply
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
|
| 77 |
-
def
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
default = {
|
| 81 |
-
"assistant_reply":
|
| 82 |
"code_snippet": "",
|
| 83 |
-
"state_updates": {"conversationSummary": "", "language": "
|
| 84 |
"suggested_tags": [],
|
|
|
|
| 85 |
}
|
| 86 |
-
|
| 87 |
-
if not raw_response or not isinstance(raw_response, str):
|
| 88 |
return default
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 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 |
-
|
| 148 |
-
if not isinstance(
|
| 149 |
return jsonify({"error": "invalid request body"}), 400
|
| 150 |
|
| 151 |
-
chat_history
|
| 152 |
-
assistant_state
|
| 153 |
|
| 154 |
-
#
|
| 155 |
-
state
|
| 156 |
-
"conversationSummary": assistant_state.get("conversationSummary", ""),
|
| 157 |
-
"language": assistant_state.get("language", "
|
| 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
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
#
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
"conversationSummary": assistant_state.get("conversationSummary", ""),
|
| 266 |
-
"language": assistant_state.get("language", "
|
| 267 |
"taggedReplies": assistant_state.get("taggedReplies", []),
|
| 268 |
}
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 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)
|