File size: 8,587 Bytes
ab5ea78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""LLM clients for the extraction stage — **the only place this pipeline spends
money.**

Two things this module is careful about:

- **Structured output is probed, not assumed.** `json_schema` needs a recent
  api_version and we cannot confirm from here what the resource exposes. The
  first call tries it; on rejection it falls back to `json_object` plus
  validate-and-retry, and records which mode actually applied.
- **Cached tokens are read from the API, never modelled.** Caching does not
  engage below the token floor, so an under-length prefix caches nothing.
  `usage.prompt_tokens_details.cached_tokens` is the only source of truth, and a
  cached price must never be reported without it.

All four branches route to the **nano** deployment (`__54n`). That is a recorded
decision, not an oversight: nano measured 0.75 schema-fill precision against a
0.80 line, and `rule`/`summary` — whose failure mode is least detectable, since
a plausible summary cannot be span-checked — run there too until a larger
deployment exists.
"""

from __future__ import annotations

import json
import time
from typing import Any

from ...config.settings import settings as app_settings
from ...middlewares.logging import get_logger
from ..models import Branch, CallUsage
from ..settings import TEMPERATURE

logger = get_logger("knowledge_extract_client")

MAX_RETRIES = 3


class LLMResult:
    def __init__(self, data: dict, usage: CallUsage, raw: str = ""):
        self.data = data
        self.usage = usage
        self.raw = raw


class AzureExtractor:
    """Real calls, real spend. Always dry-run before a corpus-scale run."""

    def __init__(self, client=None, deployment: str | None = None):
        self.deployment = deployment or app_settings.azureai_deployment_name_54n
        self._client = client or self._build_client()
        self._mode: str | None = None  # resolved on the first successful call

    @staticmethod
    def _build_client():
        from openai import AzureOpenAI

        endpoint = app_settings.azureai_endpoint_url_54n
        api_key = app_settings.azureai_api_key_54n
        if not endpoint or not api_key:
            raise RuntimeError(
                "azureai__endpoint__url__54n / azureai__api_key__54n are not set. "
                "Use the mock extractor to run without Azure."
            )
        return AzureOpenAI(
            azure_endpoint=endpoint,
            api_key=api_key,
            api_version=app_settings.azureai_api_version_54n,
        )

    def complete(
        self,
        branch: Branch,
        system_prompt: str,
        user_prompt: str,
        schema: dict,
        schema_name: str,
    ) -> LLMResult:
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ]
        last_error: Exception | None = None

        for attempt in range(MAX_RETRIES):
            mode = self._mode or "json_schema"
            started = time.time()
            try:
                response = self._client.chat.completions.create(
                    model=self.deployment,
                    messages=messages,
                    temperature=TEMPERATURE,
                    response_format=self._response_format(mode, schema, schema_name),
                )
            except Exception as exc:
                if mode == "json_schema" and self._looks_unsupported(exc):
                    logger.info(
                        "json_schema unsupported — falling back to json_object",
                        error=repr(exc),
                    )
                    self._mode = "json_object"
                    continue
                last_error = exc
                logger.warning("call failed", branch=branch, attempt=attempt, error=repr(exc))
                time.sleep(2**attempt)
                continue

            self._mode = mode
            content = response.choices[0].message.content or "{}"
            try:
                data = json.loads(content)
            except json.JSONDecodeError as exc:
                last_error = exc
                logger.warning("unparseable JSON", branch=branch, attempt=attempt)
                continue

            usage = self._usage(response, branch, time.time() - started, attempt, mode)
            return LLMResult(data, usage, content)

        raise RuntimeError(f"{branch}: all {MAX_RETRIES} attempts failed: {last_error!r}")

    @staticmethod
    def _response_format(mode: str, schema: dict, schema_name: str) -> dict:
        if mode == "json_schema":
            return {
                "type": "json_schema",
                "json_schema": {"name": schema_name, "schema": schema, "strict": False},
            }
        return {"type": "json_object"}

    @staticmethod
    def _looks_unsupported(exc: Exception) -> bool:
        text = str(exc).lower()
        return any(
            s in text
            for s in ("response_format", "json_schema", "unsupported", "invalid_request")
        )

    def _usage(
        self, response: Any, branch: Branch, latency: float, retries: int, mode: str
    ) -> CallUsage:
        usage = getattr(response, "usage", None)
        details = getattr(usage, "prompt_tokens_details", None)
        # The ONLY source of truth for caching. Absent -> cached stays 0 and the
        # uncached regime is what gets reported.
        cached = int(getattr(details, "cached_tokens", 0) or 0) if details else 0
        return CallUsage(
            branch=branch,
            deployment=self.deployment,
            tier="nano",
            prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
            cached_tokens=cached,
            completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
            latency_s=round(latency, 3),
            retries=retries,
            structured_output_mode=mode,
            simulated=False,
        )


class MockExtractor:
    """No network, no spend. Every record it produces is stamped `simulated`.

    Exercises the wiring — schema validation, span checking, escalation,
    conflicts, diff, queue — without credentials. It is **not** a model-quality
    measurement and its output must never be reported as one.

    It abstains by default (returns null definitions), because abstention is the
    dominant real behaviour: on the reference document 56 of 66 entries had no
    definition. A mock that always answers would make the downstream stages look
    far better exercised than they are.
    """

    def __init__(self, responses: dict[str, dict] | None = None, deployment: str = "mock"):
        self.responses = responses or {}
        self.deployment = deployment
        self.calls: list[tuple[str, str]] = []

    def complete(
        self,
        branch: Branch,
        system_prompt: str,
        user_prompt: str,
        schema: dict,
        schema_name: str,
    ) -> LLMResult:
        self.calls.append((branch, user_prompt))
        data = self.responses.get(branch) or self._abstain(branch, user_prompt)
        usage = CallUsage(
            branch=branch,
            deployment=self.deployment,
            prompt_tokens=len(system_prompt) // 4 + len(user_prompt) // 4,
            completion_tokens=40,
            structured_output_mode="mock",
            simulated=True,
        )
        return LLMResult(data, usage, json.dumps(data))

    @staticmethod
    def _abstain(branch: Branch, user_prompt: str) -> dict:
        # Quote a real fragment so the span check has something locatable and is
        # genuinely exercised rather than trivially passed.
        span = ""
        if "EVIDENCE" in user_prompt:
            body = user_prompt.split("EVIDENCE", 1)[1]
            for line in body.splitlines():
                if line.strip() and not line.startswith("["):
                    span = line.strip()[:60]
                    break
        prov = {"section_no": None, "page": 1, "span": span}
        if branch == "glossary":
            term = "unknown"
            for line in user_prompt.splitlines():
                if line.startswith("CANDIDATE TERM:"):
                    term = line.split(":", 1)[1].strip()
                    break
            return {"term": term, "definition": None, "provenance": prov}
        if branch == "rule":
            return {"rule_id": "r_mock", "statement": None, "provenance": prov}
        if branch == "formula":
            return {"name": None, "formula_latex": None, "provenance": prov}
        return {"title": None, "summary_md": None, "provenance": prov}