File size: 5,561 Bytes
ed8fc9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
"""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