File size: 8,309 Bytes
aacc29a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from carepath.config import Settings
from carepath.services.llm import LLMError, build_llm
from carepath.services.retrieval import RetrievedTerm
from carepath.services.scribe_local import LocalScribeLLM


def _bundle(
    root: Path, *, scope: str = "research_only", correction_mode: str = "adapter"
) -> Path:
    adapter_names = ("gec", "soap") if correction_mode == "adapter" else ("soap",)
    for name in adapter_names:
        (root / "adapters" / name).mkdir(parents=True)
    manifest = {
        "schema": "carepath.scribe.bundle/1",
        "usage_scope": scope,
        "promotion_status": "blocked_research_only",
        "base_model": "Qwen/Qwen3-4B-Instruct-2507",
        "adapters": {name: f"adapters/{name}" for name in adapter_names},
        "correction_mode": correction_mode,
    }
    (root / "scribe_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
    return root


class LocalScribeTests(unittest.TestCase):
    def test_dual_adapter_flow_is_grounded_and_identifiable(self) -> None:
        with tempfile.TemporaryDirectory() as temp:
            calls: list[str] = []

            def generate(adapter: str, prompt: str) -> str:
                calls.append(adapter)
                task = json.loads(prompt)["task"]
                if task == "correct_asr_transcript":
                    return "Bệnh nhân dùng metformin 500 mg"
                if task == "extract_grounded_clinical_facts":
                    transcript = "Bệnh nhân dùng metformin 500 mg"
                    span = "dùng metformin 500 mg"
                    start = transcript.index(span)
                    return json.dumps(
                        {
                            "facts": [
                                {
                                    "type": "medication",
                                    "value": "metformin 500 mg",
                                    "negated": False,
                                    "uncertain": False,
                                    "source_span": {
                                        "start": start,
                                        "end": start + len(span),
                                        "text": span,
                                    },
                                }
                            ]
                        }
                    )
                return json.dumps(
                    {
                        "subjective": "metformin 500 mg",
                        "objective": "Chưa có thông tin khách quan.",
                        "assessment": "Chưa có đánh giá trong bản ghi.",
                        "plan": "metformin 500 mg",
                        "missing_information": ["Đánh giá"],
                        "review_required": False,
                    }
                )

            llm = LocalScribeLLM(_bundle(Path(temp)), generate_fn=generate)
            terms = [RetrievedTerm("metformin", 1.0, "drug", "test")]
            correction = llm.correct_transcript("Benh nhan dung metformin 500 mg", terms)
            result = llm.generate_soap(correction.corrected_text, terms)

            self.assertEqual(correction.provider, "scribe_local")
            self.assertEqual(result.provider, "scribe_local")
            self.assertTrue(result.soap.review_required)
            self.assertEqual(calls, ["gec", "soap", "soap"])
            ready, details = llm.readiness()
            self.assertTrue(ready)
            self.assertEqual(details["promotion_status"], "blocked_research_only")
            self.assertEqual(details["fallback"], "disabled")

    def test_unsupported_fact_and_number_fail_closed(self) -> None:
        with tempfile.TemporaryDirectory() as temp:
            outputs = iter(
                [
                    json.dumps(
                        {
                            "facts": [
                                {
                                    "type": "medication",
                                    "value": "warfarin 5 mg",
                                    "source_span": {
                                        "start": 0,
                                        "end": 13,
                                        "text": "warfarin 5 mg",
                                    },
                                }
                            ]
                        }
                    )
                ]
            )
            llm = LocalScribeLLM(
                _bundle(Path(temp)), generate_fn=lambda adapter, prompt: next(outputs)
            )
            with self.assertRaises(LLMError):
                llm.generate_soap("Bệnh nhân dùng metformin 500 mg", [])

    def test_writer_cannot_append_text_outside_grounded_fact_values(self) -> None:
        with tempfile.TemporaryDirectory() as temp:
            transcript = "Bác sĩ đánh giá viêm họng"
            span = "viêm họng"
            start = transcript.index(span)
            outputs = iter(
                [
                    json.dumps(
                        {
                            "facts": [
                                {
                                    "type": "assessment",
                                    "value": span,
                                    "negated": False,
                                    "uncertain": False,
                                    "source_span": {
                                        "start": start,
                                        "end": start + len(span),
                                        "text": span,
                                    },
                                }
                            ]
                        }
                    ),
                    json.dumps(
                        {
                            "subjective": "Chưa có thông tin chủ quan.",
                            "objective": "Chưa có thông tin khách quan.",
                            "assessment": "viêm họng; ung thư",
                            "plan": "Chưa có kế hoạch trong bản ghi.",
                            "missing_information": [],
                            "review_required": True,
                        }
                    ),
                ]
            )
            llm = LocalScribeLLM(
                _bundle(Path(temp), correction_mode="identity"),
                generate_fn=lambda adapter, prompt: next(outputs),
            )

            with self.assertRaisesRegex(LLMError, "outside grounded fact values"):
                llm.generate_soap(transcript, [])

    def test_manifest_cannot_claim_promotable_scope(self) -> None:
        with tempfile.TemporaryDirectory() as temp:
            with self.assertRaisesRegex(ValueError, "research_only"):
                LocalScribeLLM(_bundle(Path(temp), scope="production"))

    def test_soap_only_bundle_declares_identity_correction(self) -> None:
        with tempfile.TemporaryDirectory() as temp:
            llm = LocalScribeLLM(_bundle(Path(temp), correction_mode="identity"))

            correction = llm.correct_transcript("Giữ nguyên bản ghi", [])
            ready, details = llm.readiness()

            self.assertTrue(ready)
            self.assertEqual(correction.corrected_text, "Giữ nguyên bản ghi")
            self.assertEqual(correction.provider, "scribe_local_identity")
            self.assertEqual(details["adapters"], ["soap"])

    def test_build_llm_requires_explicit_staging_bundle_without_fallback(self) -> None:
        with tempfile.TemporaryDirectory() as temp:
            _bundle(Path(temp))
            with patch.dict(
                os.environ,
                {
                    "LLM_PROVIDER": "scribe_local",
                    "SCRIBE_BUNDLE_PATH": temp,
                    "LLM_FALLBACK_OFFLINE": "false",
                },
                clear=True,
            ):
                llm = build_llm(Settings.from_env())
            self.assertIsInstance(llm, LocalScribeLLM)


if __name__ == "__main__":
    unittest.main()