"""Optional local JSON parser for non-JSON upstream responses. The parser talks to any OpenAI-compatible chat-completions endpoint, for example Ollama at ``http://127.0.0.1:11434/v1``. It is opt-in: set ``LOCAL_PARSER_URL`` and ``LOCAL_PARSER_MODEL`` and non-JSON external results are sent to the local model and converted to JSON. The parser is a converter, not a second analyst. Its prompt tells the model to preserve the source facts and never invent prices, targets, or recommendations. If parsing fails, the app keeps the raw response and returns a clear error. """ from __future__ import annotations import json import os from typing import Any import requests DEFAULT_MODEL = "llama3.1" DEFAULT_TIMEOUT_SECONDS = 90.0 MAX_INPUT_CHARS = 12000 MAX_OUTPUT_TOKENS = 1600 SYSTEM_PROMPT = ( "You convert raw stock-analysis output into strict JSON. " "Return only one JSON object. Do not use markdown or code fences. " "Preserve the source facts exactly. Do not invent prices, targets, " "ratings, or recommendations. If a field is missing, leave it out." ) _settings: dict[str, Any] = { "url": "", "model": DEFAULT_MODEL, "timeout": DEFAULT_TIMEOUT_SECONDS, } class LocalParserError(RuntimeError): """Raised when the local model cannot produce valid JSON.""" def configure( *, url: str | None = None, model: str | None = None, timeout: float | None = None, ) -> dict[str, Any]: """Apply settings from ``app.py``. All values are optional.""" if url is not None: _settings["url"] = str(url).strip() if model is not None: _settings["model"] = str(model).strip() if timeout is not None: _settings["timeout"] = float(timeout) return settings() def settings() -> dict[str, Any]: """Return a copy of the current settings, without any credentials.""" return { "configured": is_configured(), "model": _resolved_model(), "timeout": float(_settings["timeout"]), } def is_configured() -> bool: return bool(_resolved_url()) and bool(_resolved_model()) def model_name() -> str: return _resolved_model() def _resolved_url() -> str: return str(_settings["url"] or os.getenv("LOCAL_PARSER_URL", "")).strip() def _resolved_model() -> str: return str(_settings["model"] or os.getenv("LOCAL_PARSER_MODEL", DEFAULT_MODEL)).strip() def _chat_url(base: str) -> str: url = base.rstrip("/") if url.endswith("/chat/completions"): return url if url.endswith("/v1"): return f"{url}/chat/completions" return f"{url}/v1/chat/completions" def _extract_json(text: str) -> Any: candidate = text.strip() if candidate.startswith("```"): lines = candidate.splitlines() if lines and lines[0].startswith("```"): lines = lines[1:] if lines and lines[-1].strip().startswith("```"): lines = lines[:-1] candidate = "\n".join(lines).strip() try: return json.loads(candidate) except ValueError: pass starts = [index for index in (candidate.find("{"), candidate.find("[")) if index != -1] if not starts: raise ValueError("no JSON value found") start = min(starts) ends = [candidate.rfind(char) for char in ("}", "]")] ends = [index for index in ends if index != -1] if not ends: raise ValueError("no JSON value end found") end = max(ends) if end <= start: raise ValueError("invalid JSON span") return json.loads(candidate[start : end + 1]) def parse_json(raw_text: str, ticker: str = "") -> Any: """Convert raw upstream text into JSON using the configured local model.""" if not is_configured(): raise LocalParserError("The local parser is not configured.") if not raw_text.strip(): raise LocalParserError("The upstream response was empty.") model = _resolved_model() user_content = ( f"Ticker: {ticker or 'unknown'}\n\n" "Raw analysis output:\n" f"{raw_text[:MAX_INPUT_CHARS]}" ) payload = { "model": model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ], "temperature": 0, "max_tokens": MAX_OUTPUT_TOKENS, "stream": False, } try: response = requests.post( _chat_url(_resolved_url()), json=payload, timeout=float(_settings["timeout"]), ) except requests.RequestException as exc: raise LocalParserError( f"Local parser request failed ({type(exc).__name__})." ) from exc if not response.ok: detail = (response.text or "").strip()[:200] raise LocalParserError( f"Local parser returned HTTP {response.status_code}: {detail}" ) try: data = response.json() except ValueError as exc: raise LocalParserError("Local parser returned a non-JSON response.") from exc try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: raise LocalParserError( "Local parser response did not contain message content." ) from exc if not isinstance(content, str) or not content.strip(): raise LocalParserError("Local parser returned empty content.") try: return _extract_json(content) except ValueError as exc: raise LocalParserError("Local parser output was not valid JSON.") from exc