StockAnalysis / local_parser.py
igardner's picture
feat: add external analysis adapter and local JSON parser
ed8fc9e
Raw History Blame Contribute Delete
5.56 kB
"""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