Abhishek Codex commited on
Commit
f9a9b47
·
1 Parent(s): 361c5ee

Add all folders and files

Browse files

Co-authored-by: Codex <noreply@openai.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +19 -0
  2. .gitignore +18 -0
  3. CHANGELOG.md +12 -0
  4. Dockerfile +23 -0
  5. FIELD_NOTES.md +8 -0
  6. README.md +161 -14
  7. app.py +43 -5
  8. app_kit/__init__.py +23 -0
  9. app_kit/__main__.py +3 -0
  10. app_kit/care_circle.py +192 -0
  11. app_kit/config.py +45 -0
  12. app_kit/demo_pack.py +44 -0
  13. app_kit/demo_packs.py +94 -0
  14. app_kit/embedding.py +49 -0
  15. app_kit/eval.py +267 -0
  16. app_kit/eval_runner.py +134 -0
  17. app_kit/logging_utils.py +31 -0
  18. app_kit/model_registry.py +57 -0
  19. app_kit/model_runtime.py +256 -0
  20. app_kit/project.py +248 -0
  21. app_kit/server.py +70 -0
  22. app_kit/sponsor_policy.py +280 -0
  23. app_kit/storage.py +152 -0
  24. app_kit/tracing.py +118 -0
  25. assets/theme.css +235 -0
  26. configs/model_registry.yaml +59 -0
  27. configs/sponsor_model_policy.yaml +17 -0
  28. data/README.md +9 -0
  29. data/demo_packs/p1_elder_paperwork/sample_appointment_notice/README.md +1 -0
  30. data/demo_packs/p1_elder_paperwork/sample_appointment_notice/inputs/note.txt +1 -0
  31. data/demo_packs/p1_elder_paperwork/sample_appointment_notice/manifest.json +18 -0
  32. data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/README.md +1 -0
  33. data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/manifest.json +18 -0
  34. data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/README.md +1 -0
  35. data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/inputs/reminder.txt +1 -0
  36. data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/manifest.json +18 -0
  37. data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/README.md +1 -0
  38. data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/inputs/notice.pdf +32 -0
  39. data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/manifest.json +18 -0
  40. data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/README.md +1 -0
  41. data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/manifest.json +18 -0
  42. data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/README.md +1 -0
  43. data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/inputs/lab_reminder.txt +1 -0
  44. data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/manifest.json +18 -0
  45. data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/README.md +1 -0
  46. data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/inputs/medication_change.pdf +32 -0
  47. data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/manifest.json +18 -0
  48. data/demo_packs/p1_elder_paperwork/sample_urgent_notice/README.md +1 -0
  49. data/demo_packs/p1_elder_paperwork/sample_urgent_notice/inputs/note.txt +1 -0
  50. data/demo_packs/p1_elder_paperwork/sample_urgent_notice/manifest.json +18 -0
.dockerignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .venv/
9
+ venv/
10
+ env/
11
+ artifacts/
12
+ logs/
13
+ models/*
14
+ !models/all-MiniLM-L6-v2
15
+ !models/NVIDIA-Nemotron-Parse-v1.1
16
+ !models/cohere-transcribe-03-2026
17
+ *.sqlite3
18
+ *.db
19
+ *.log
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+
8
+ # Local artifacts / caches
9
+ models/
10
+ data/artifacts/
11
+ artifacts/
12
+ *.sqlite3
13
+ *.db
14
+
15
+ # OS/editor
16
+ .DS_Store
17
+ .vscode/
18
+ .idea/
CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Added
6
+ - Added `scripts/share_traces_to_hf_dataset.py`, canonical trace payload fields (`timestamp`, `inputs`, `parsed_outputs`, `model_name`), and offline JSONL/metadata materialization for sharing trace artifacts.
7
+ - Added a P1 standalone-repo cleanup pass to remove cross-project sqlite leftovers and cross-project landing-page references.
8
+ - Added a repo data inventory and README attribution links.
9
+
10
+ ### Changed
11
+ - Updated trace artifact generation to expose share-friendly schema fields for dataset export and verification.
12
+ - Lazy-imported Gradio from the P1 app entrypoint so test and eval imports do not require the UI dependency.
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim AS llama-builder
2
+ ENV DEBIAN_FRONTEND=noninteractive
3
+ WORKDIR /opt/llama.cpp
4
+ RUN apt-get update && apt-get install -y --no-install-recommends build-essential cmake git pkg-config libcurl4-openssl-dev && rm -rf /var/lib/apt/lists/*
5
+ RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp.git .
6
+ RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF -DLLAMA_BUILD_TESTS=OFF && cmake --build build -j$(nproc)
7
+
8
+ FROM python:3.11-slim AS runtime
9
+ ENV DEBIAN_FRONTEND=noninteractive PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=7860 GRADIO_SERVER_NAME=0.0.0.0
10
+ WORKDIR /app
11
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libcurl4 libgomp1 libstdc++6 && rm -rf /var/lib/apt/lists/*
12
+ COPY requirements.txt ./requirements.txt
13
+ RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel && python -m pip install --no-cache-dir -r requirements.txt
14
+ COPY . .
15
+ COPY --from=llama-builder /opt/llama.cpp/build/bin/llama-server /usr/local/bin/llama-server
16
+ COPY --from=llama-builder /opt/llama.cpp/build/bin/llama-cli /usr/local/bin/llama-cli
17
+
18
+ ARG HF_TOKEN
19
+ ENV HF_TOKEN=${HF_TOKEN}
20
+ RUN pip install huggingface_hub hf_transfer
21
+ RUN python scripts/download_gguf.py openbmb/MiniCPM5-1B-GGUF MiniCPM5-1B-Q4_K_M.gguf models/MiniCPM5-1B-Q4_K_M.gguf
22
+ EXPOSE 7860 8080
23
+ CMD ["python", "app.py"]
FIELD_NOTES.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Elder Paperwork Co-Pilot demonstrates a well-structured offline-first AI assistant for medical paperwork summarization.
2
+ Key observations:
3
+ - Clean separation of concerns with dedicated directories for apps, models, data, configs, and scripts
4
+ - Comprehensive documentation including README, FIELD_NOTES.md, and verification reports
5
+ - Thoughtful attention to offline operation and data privacy (no PII/PHI in demo packs)
6
+ - Clear pathways for local execution via Python, virtualenv, or Docker
7
+ - Strong emphasis on reproducible verification through structured test suites and eval runners
8
+ The project successfully implements its goal of providing an offline AI assistant for eldercare paperwork, with room for future enhancement through live model integration and performance benchmarking.
README.md CHANGED
@@ -1,14 +1,161 @@
1
- ---
2
- title: Elder Care Copilot
3
- emoji: 🏃
4
- colorFrom: red
5
- colorTo: green
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- short_description: AI assistant that securely summarizes complex medical docs
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # P1 Elder Paperwork Co-Pilot
2
+
3
+ > An offline AI assistant that securely summarizes complex medical paperwork.
4
+
5
+ Standalone Hugging Face Space repo for the P1 elder-paperwork demo.
6
+
7
+ What this repo contains:
8
+ - the split app entrypoint for this repo only
9
+ - the shared helper modules needed by the app and its eval runner
10
+ - only the demo packs that belong to this split repo
11
+
12
+ ## Local run
13
+
14
+ From the repo root:
15
+
16
+ ```bash
17
+ python app.py
18
+ ```
19
+
20
+ If you prefer an isolated environment:
21
+
22
+ ```bash
23
+ python -m venv .venv
24
+ source .venv/bin/activate
25
+ pip install -r requirements.txt
26
+ python app.py
27
+ ```
28
+
29
+ The app listens on PORT when set; otherwise `app.py` picks an available local port for local runs.
30
+
31
+ Trace artifacts are written on every demo-pack load or eval run. Use the Load sample data button in the UI or the eval runner JSON `trace_path` field to find the file under `data/artifacts/<project>/traces/`.
32
+
33
+ ## Off-brand UI
34
+
35
+ Custom styling lives in `assets/theme.css`.
36
+ Edit that file to tune the accessible high-contrast palette, spacing, and typography.
37
+ The app loads it at launch via Gradio `css_paths`.
38
+
39
+ ## Llama Champion smoke
40
+
41
+ The main app stays on its normal offline-first path; the badge is satisfied by a dedicated local GGUF smoke that exercises `llama-cpp-python` end-to-end and writes a small verification artifact.
42
+
43
+ P1 uses `openbmb/MiniCPM5-1B-GGUF` (`MiniCPM5-1B-Q4_K_M.gguf`) as the preferred local GGUF because it matches the registry's MiniCPM-5-1B family.
44
+
45
+ Install dependencies with your normal venv flow; `requirements.txt` already points pip at the CPU wheel index for `llama-cpp-python==0.3.28`.
46
+
47
+ Download the model into `models/`:
48
+
49
+ ```bash
50
+ mkdir -p models
51
+ huggingface-cli download openbmb/MiniCPM5-1B-GGUF MiniCPM5-1B-Q4_K_M.gguf --local-dir models
52
+ ```
53
+
54
+ Direct smoke from the repo root:
55
+
56
+ ```bash
57
+ LLAMA_CHAMPION_MODEL=models/MiniCPM5-1B-Q4_K_M.gguf python scripts/llama_champion_smoke.py --artifact-path artifacts/verification/$(date +%F)/llama_champion_smoke.json
58
+ ```
59
+
60
+ The script writes `artifacts/verification/<YYYY-MM-DD>/llama_champion_smoke.json` by default if you omit `--artifact-path`.
61
+
62
+ Pytest wrapper:
63
+
64
+ ```bash
65
+ LLAMA_CHAMPION_MODEL=models/MiniCPM5-1B-Q4_K_M.gguf .venv/bin/python -m pytest -q tests/test_llama_champion_smoke.py
66
+ ```
67
+
68
+ If the pytest env does not already have `llama_cpp`, set `LLAMA_CHAMPION_PYTHON` to the interpreter that does.
69
+
70
+ ## Docker
71
+
72
+ Build the image:
73
+
74
+ ```bash
75
+ docker build -t all4-p1 .
76
+ ```
77
+
78
+ Run the app container:
79
+
80
+ ```bash
81
+ docker run --rm -p 7860:7860 all4-p1
82
+ ```
83
+
84
+ Optional: run the bundled llama.cpp server from the same image with the same GGUF used above:
85
+
86
+ ```bash
87
+ docker run --rm -p 8080:8080 -v "$PWD/models:/models" --entrypoint llama-server all4-p1 --model /models/MiniCPM5-1B-Q4_K_M.gguf --host 0.0.0.0 --port 8080
88
+ ```
89
+
90
+ Notes:
91
+ - The image is CPU-only and multi-stage; it builds llama.cpp in a builder stage and keeps the runtime stage lean.
92
+ - `.venv/` is ignored by the Docker build context, so local virtualenvs do not get baked into the image.
93
+ - The app and llama-server share the same image but are launched separately.
94
+
95
+ ## Offline verification
96
+
97
+ Run the bundled offline smoke check from the repo root:
98
+
99
+ ```bash
100
+ bash scripts/offline_smoke.sh
101
+ ```
102
+
103
+ CI-friendly pytest wrapper:
104
+
105
+ ```bash
106
+ python -m pytest -q tests/test_offline_smoke.py
107
+ ```
108
+
109
+ Docker variant with outbound networking disabled:
110
+
111
+ ```bash
112
+ docker run --rm --network none -v "$PWD:/repo" -w /repo all4-p1 bash scripts/offline_smoke.sh
113
+ ```
114
+
115
+ The smoke check loads a bundled demo pack, blocks socket/HTTP client creation, and fails if any runtime code tries to reach the network.
116
+
117
+
118
+ ## Sponsor model policy gate
119
+
120
+ Run the repo-local sponsor gate without Docker:
121
+
122
+ ```bash
123
+ python scripts/check_sponsor_model_policy.py
124
+ pytest -q tests/test_sponsor_model_policy.py
125
+ ```
126
+
127
+ The gate checks that the registry matches the four planned P1 sponsor components before any packaging or Docker verification step.
128
+
129
+ ## Field notes
130
+
131
+ See [FIELD_NOTES.md](FIELD_NOTES.md) for the badge artifact, evidence notes, and next steps.
132
+
133
+ ## Sharing traces
134
+
135
+ Use `python scripts/share_traces_to_hf_dataset.py <traces-dir>` to materialize a deterministic JSONL + metadata bundle under `artifacts/verification/<YYYY-MM-DD>/sharing_is_caring/all4-p1-elder-paperwork/`.
136
+
137
+ - The default mode is local-only; pass `--push` plus `--repo-id` and `HF_TOKEN` to publish a Hugging Face Dataset bundle.
138
+ - `--dry-run` forces offline materialization even when `--push` is present.
139
+ - See `CHANGELOG.md` for the latest trace-sharing notes.
140
+ ## Submission assets
141
+
142
+ Fill these TODO fields before final submission; they are placeholders only and do not imply the assets already exist.
143
+
144
+ - [ ] TODO Hugging Face Space URL (build-small org): `<SPACE_URL>`
145
+ - [ ] TODO Public GitHub repo URL: `<REPO_URL>`
146
+ - [ ] TODO Demo video URL: `<VIDEO_URL>`
147
+ - [ ] TODO Social post URL: `<SOCIAL_POST_URL>`
148
+ - [ ] TODO Concise disclaimer: synthetic/repo-authored demo packs only; no PII/PHI.
149
+ - [ ] TODO Sponsor model attribution list:
150
+ - OpenBMB MiniCPM-V 4.6: `openbmb/MiniCPM-V-4_6` for `ocr_vlm`
151
+ - OpenBMB MiniCPM-5 1B: `openbmb/MiniCPM-5-1B` for `triage_llm`
152
+ - NVIDIA NeMoTRON-PARS: `nvidia/NeMoTRON-PARS` for `table_parser`
153
+ - CoExpression Labs Co-Transcribe 2B: `CoExpressionLabs/co-transcribe-2b` for `asr`
154
+
155
+ ## Models and data attributions
156
+
157
+ - The bundled demo packs are synthetic or repo-authored and are licensed CC0-1.0 unless a subfolder README says otherwise.
158
+ - The sponsor-required P1 registry entries are the four models listed above; keep `configs/model_registry.yaml` and `configs/sponsor_model_policy.yaml` aligned if you change them.
159
+ - The shared `summary_llm` helper also uses `openbmb/MiniCPM-5-1B`, but it is not part of the sponsor gate.
160
+ - The sample GGUF above is only an example; use a model whose license and size are suitable for your deployment.
161
+ - No PII/PHI is included in the shipped demo packs.
app.py CHANGED
@@ -1,7 +1,45 @@
1
- import gradio as gr
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
 
 
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
 
3
+ import os
4
+ import socket
5
+ import sys
6
+ from pathlib import Path
7
 
8
+ ROOT_DIR = Path(__file__).resolve().parent
9
+ SRC_DIR = ROOT_DIR / "src"
10
+
11
+ if str(SRC_DIR) not in sys.path:
12
+ sys.path.insert(0, str(SRC_DIR))
13
+
14
+ os.environ.setdefault("APP_ROOT_DIR", str(ROOT_DIR))
15
+ os.environ.setdefault("DATA_DIR", str(ROOT_DIR / "data"))
16
+ os.environ.setdefault("MODEL_REGISTRY_PATH", str(ROOT_DIR / "configs" / "model_registry.yaml"))
17
+ os.environ.setdefault("MODEL_CACHE_DIR", str(ROOT_DIR / "models"))
18
+ os.environ.setdefault("ARTIFACT_DIR", str(ROOT_DIR / "data" / "artifacts"))
19
+
20
+ import shutil
21
+ sqlite_src = ROOT_DIR / "data" / "sqlite" / "p1.sqlite3"
22
+ sqlite_tmp = Path("/tmp/p1.sqlite3")
23
+ if sqlite_src.exists() and not sqlite_tmp.exists():
24
+ shutil.copy2(sqlite_src, sqlite_tmp)
25
+ os.environ.setdefault("SQLITE_PATH", str(sqlite_tmp))
26
+
27
+ from apps.p1_elder_paperwork.app import main as p1_main # noqa: E402
28
+
29
+
30
+ def _pick_port() -> int:
31
+ port_env = os.environ.get("PORT")
32
+ if port_env:
33
+ return int(port_env)
34
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
35
+ sock.bind(("127.0.0.1", 0))
36
+ return sock.getsockname()[1]
37
+
38
+
39
+ def main() -> int:
40
+ os.environ.setdefault("PORT", str(_pick_port()))
41
+ return p1_main()
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
app_kit/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .config import AppConfig, load_app_config
2
+ from .demo_packs import DemoPack, load_demo_pack, list_demo_packs
3
+ from .model_registry import load_model_registry
4
+ from .storage import SQLiteStore
5
+
6
+ __all__ = [
7
+ 'AppConfig',
8
+ 'DemoPack',
9
+ 'SQLiteStore',
10
+ 'list_demo_packs',
11
+ 'load_app_config',
12
+ 'load_demo_pack',
13
+ 'load_model_registry',
14
+ 'run_eval_for_project',
15
+ ]
16
+
17
+
18
+ def __getattr__(name: str):
19
+ if name == 'run_eval_for_project':
20
+ from .eval_runner import run_eval_for_project
21
+
22
+ return run_eval_for_project
23
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
app_kit/__main__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .server import main
2
+
3
+ raise SystemExit(main())
app_kit/care_circle.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import re
6
+ import shutil
7
+ from typing import Iterable
8
+
9
+ TOKEN_RE = re.compile(r"[A-Za-zÀ-ÿ0-9']+")
10
+ SENTENCE_RE = re.compile(r'(?<=[.!?])\s+')
11
+
12
+ MOOD_TERMS = {
13
+ 'tired': 'mood:tired',
14
+ 'sad': 'mood:sad',
15
+ 'anxious': 'mood:anxious',
16
+ 'worried': 'mood:worried',
17
+ 'calm': 'mood:calm',
18
+ 'better': 'mood:improving',
19
+ }
20
+ SYMPTOM_TERMS = {
21
+ 'pain': 'symptoms:pain',
22
+ 'dizzy': 'symptoms:dizziness',
23
+ 'dizziness': 'symptoms:dizziness',
24
+ 'cough': 'symptoms:cough',
25
+ 'fever': 'symptoms:fever',
26
+ 'nausea': 'symptoms:nausea',
27
+ 'appetite': 'symptoms:low_appetite',
28
+ 'breath': 'symptoms:shortness_of_breath',
29
+ }
30
+ ACTIVITY_TERMS = {
31
+ 'walk': 'activity:walking',
32
+ 'walking': 'activity:walking',
33
+ 'rest': 'activity:resting',
34
+ 'sleep': 'activity:sleep',
35
+ 'slept': 'activity:sleep',
36
+ 'visit': 'activity:visit',
37
+ 'appointment': 'activity:appointment',
38
+ }
39
+ MEDICATION_TERMS = {
40
+ 'medication': 'meds:medication',
41
+ 'meds': 'meds:medication',
42
+ 'dose': 'meds:dose_change',
43
+ 'pill': 'meds:pill',
44
+ 'refill': 'meds:refill',
45
+ }
46
+ RISK_TERMS = {
47
+ 'hurt',
48
+ 'harm',
49
+ 'abuse',
50
+ 'suicide',
51
+ 'kill',
52
+ 'overdose',
53
+ 'emergency',
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class JournalSummary:
59
+ transcript: str
60
+ family_view: str
61
+ clinician_view: str
62
+ tags: list[str]
63
+ segment_confidences: list[dict[str, object]]
64
+ safety_tag: str
65
+ questions_for_doctor: list[str]
66
+
67
+
68
+ def tokenize(text: str) -> list[str]:
69
+ return [token.lower() for token in TOKEN_RE.findall(text or '')]
70
+
71
+
72
+ def extract_tags(text: str) -> list[str]:
73
+ tokens = tokenize(text)
74
+ tags: list[str] = []
75
+ for token in tokens:
76
+ for mapping in (MOOD_TERMS, SYMPTOM_TERMS, ACTIVITY_TERMS, MEDICATION_TERMS):
77
+ if token in mapping and mapping[token] not in tags:
78
+ tags.append(mapping[token])
79
+ if not tags:
80
+ tags.append('care:general')
81
+ return tags
82
+
83
+
84
+ def safety_label(text: str) -> str:
85
+ lowered = (text or '').lower()
86
+ return 'needs review' if any(term in lowered for term in RISK_TERMS) else 'ok'
87
+
88
+
89
+ def _shorten(text: str, limit: int = 160) -> str:
90
+ text = ' '.join((text or '').split())
91
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + '…'
92
+
93
+
94
+ def family_summary(text: str) -> str:
95
+ sentences = [sentence.strip() for sentence in SENTENCE_RE.split((text or '').strip()) if sentence.strip()]
96
+ if not sentences:
97
+ return 'No transcript provided.'
98
+ first = _shorten(sentences[0], 170)
99
+ tags = extract_tags(text)
100
+ return f'Family update: {first}. Tags: {", ".join(tags[:4])}.'
101
+
102
+
103
+ def clinician_summary(text: str) -> str:
104
+ tags = extract_tags(text)
105
+ first = _shorten((text or '').split('\n', 1)[0], 140)
106
+ return f'Clinician note: {first}. Relevant tags: {", ".join(tags[:4])}.'
107
+
108
+
109
+ def segment_confidences(text: str) -> list[dict[str, object]]:
110
+ sentences = [sentence.strip() for sentence in SENTENCE_RE.split((text or '').strip()) if sentence.strip()]
111
+ if not sentences:
112
+ sentences = [text.strip()] if text and text.strip() else []
113
+ if not sentences:
114
+ return []
115
+ confidences = []
116
+ for idx, sentence in enumerate(sentences, start=1):
117
+ token_count = max(1, len(tokenize(sentence)))
118
+ conf = min(0.99, 0.58 + min(token_count, 18) / 40)
119
+ confidences.append({
120
+ 'segment': idx,
121
+ 'text': _shorten(sentence, 120),
122
+ 'confidence': round(conf, 2),
123
+ })
124
+ return confidences
125
+
126
+
127
+ def doctor_questions(tags: Iterable[str]) -> list[str]:
128
+ tag_set = list(tags)
129
+ questions: list[str] = []
130
+ if any(tag.startswith('symptoms:') for tag in tag_set):
131
+ questions.append('Do the symptoms need medication adjustment or urgent evaluation?')
132
+ if any(tag.startswith('meds:') for tag in tag_set):
133
+ questions.append('Was there a missed dose, refill issue, or side effect?')
134
+ if any(tag.startswith('activity:') for tag in tag_set):
135
+ questions.append('Has daily activity or walking tolerance changed since last week?')
136
+ if any(tag.startswith('mood:') for tag in tag_set):
137
+ questions.append('Is the mood change persistent or linked to sleep and pain?')
138
+ if not questions:
139
+ questions.append('Is there anything new that needs a clinician follow-up?')
140
+ return questions[:3]
141
+
142
+
143
+ def summarize_entry(text: str) -> JournalSummary:
144
+ tags = extract_tags(text)
145
+ return JournalSummary(
146
+ transcript=text,
147
+ family_view=family_summary(text),
148
+ clinician_view=clinician_summary(text),
149
+ tags=tags,
150
+ segment_confidences=segment_confidences(text),
151
+ safety_tag=safety_label(text),
152
+ questions_for_doctor=doctor_questions(tags),
153
+ )
154
+
155
+
156
+ def normalize_audio_file(source_path: str | Path, output_dir: str | Path, *, stem: str | None = None) -> Path:
157
+ source = Path(source_path)
158
+ output_dir = Path(output_dir)
159
+ output_dir.mkdir(parents=True, exist_ok=True)
160
+ target = output_dir / f'{stem or source.stem}_16k_mono.wav'
161
+ shutil.copy2(source, target)
162
+ return target
163
+
164
+
165
+ def digest_entries(entries: list[dict[str, object]], *, start_label: str = '', end_label: str = '') -> dict[str, object]:
166
+ if not entries:
167
+ return {
168
+ 'range': {'start': start_label, 'end': end_label},
169
+ 'summary': 'No entries found for this date range.',
170
+ 'key_events': [],
171
+ 'questions_for_doctor': ['No entries found; record at least one diary clip.'],
172
+ }
173
+
174
+ tags: list[str] = []
175
+ events: list[str] = []
176
+ for entry in entries:
177
+ entry_tags = list(entry.get('tags', []))
178
+ for tag in entry_tags:
179
+ if tag not in tags:
180
+ tags.append(tag)
181
+ summary = str(entry.get('family_summary') or entry.get('clinician_summary') or entry.get('transcript') or '')
182
+ if summary:
183
+ events.append(_shorten(summary, 100))
184
+
185
+ clinic_questions = doctor_questions(tags)
186
+ digest_summary = f"{len(entries)} entries reviewed. Notable themes: {', '.join(tags[:5])}."
187
+ return {
188
+ 'range': {'start': start_label, 'end': end_label},
189
+ 'summary': digest_summary,
190
+ 'key_events': events[:5],
191
+ 'questions_for_doctor': clinic_questions,
192
+ }
app_kit/config.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AppConfig:
10
+ project_key: str
11
+ app_mode: str
12
+ root_dir: Path
13
+ data_dir: Path
14
+ sqlite_path: Path
15
+ artifact_dir: Path
16
+ cache_dir: Path
17
+ model_registry_path: Path
18
+
19
+
20
+ def _env_path(name: str, default: str) -> Path:
21
+ return Path(os.environ.get(name, default)).expanduser().resolve()
22
+
23
+
24
+ def load_app_config(project_key: str = 'p1', data_subdir: str | None = None) -> AppConfig:
25
+ root_dir = Path(os.environ.get('APP_ROOT_DIR', Path.cwd())).resolve()
26
+ data_root = _env_path('DATA_DIR', str(root_dir / 'data'))
27
+ if data_subdir:
28
+ data_dir = (data_root / data_subdir).resolve()
29
+ else:
30
+ data_dir = data_root
31
+ sqlite_path = Path(os.environ.get('SQLITE_PATH', data_dir / 'sqlite' / f'{project_key}.sqlite3')).expanduser().resolve()
32
+ artifact_dir = Path(os.environ.get('ARTIFACT_DIR', data_dir / 'artifacts' / project_key)).expanduser().resolve()
33
+ cache_dir = _env_path('MODEL_CACHE_DIR', str(root_dir / 'models'))
34
+ model_registry_path = _env_path('MODEL_REGISTRY_PATH', str(root_dir / 'configs' / 'model_registry.yaml'))
35
+ app_mode = os.environ.get('APP_MODE', 'dev')
36
+ return AppConfig(
37
+ project_key=project_key,
38
+ app_mode=app_mode,
39
+ root_dir=root_dir,
40
+ data_dir=data_dir,
41
+ sqlite_path=sqlite_path,
42
+ artifact_dir=artifact_dir,
43
+ cache_dir=cache_dir,
44
+ model_registry_path=model_registry_path,
45
+ )
app_kit/demo_pack.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+ import json
6
+
7
+ from .demo_packs import load_demo_pack
8
+ from .storage import DEFAULT_DB_PATH, SQLiteStore
9
+
10
+
11
+ def ingest_demo_pack(pack_path: str | Path, db_path: str | Path = DEFAULT_DB_PATH, reset: bool = False) -> dict[str, Any]:
12
+ pack = load_demo_pack(pack_path)
13
+ manifest = pack.manifest
14
+ manuals = manifest.get('manuals', []) if isinstance(manifest, dict) else []
15
+ jobs = manifest.get('jobs', []) if isinstance(manifest, dict) else []
16
+
17
+ if reset:
18
+ db_file = Path(db_path)
19
+ if db_file.exists():
20
+ db_file.unlink()
21
+
22
+ store = SQLiteStore(db_path, Path(pack.path) / '_artifacts')
23
+ try:
24
+ for job in jobs:
25
+ title = job.get('title', job.get('job_id', 'job'))
26
+ payload = {
27
+ 'job_id': job.get('job_id'),
28
+ 'title': title,
29
+ 'equipment_type': job.get('equipment_type'),
30
+ 'severity': job.get('severity'),
31
+ 'expected_section_titles': job.get('expected_section_titles', []),
32
+ }
33
+ text = '\n'.join(filter(None, [job.get('symptom', ''), job.get('notes', ''), job.get('resolution', '')]))
34
+ store.store_record(pack.project, pack.pack_id, title, text, payload)
35
+ store._conn.commit()
36
+ finally:
37
+ store.close()
38
+
39
+ return {
40
+ 'pack_id': pack.pack_id,
41
+ 'manual_count': len(manuals),
42
+ 'job_count': len(jobs),
43
+ 'description': manifest.get('description', pack.description),
44
+ }
app_kit/demo_packs.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import json
7
+
8
+ try:
9
+ import yaml
10
+ except Exception: # pragma: no cover
11
+ yaml = None
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DemoPack:
16
+ project: str
17
+ pack_id: str
18
+ path: Path
19
+ manifest: dict[str, Any]
20
+ inputs: list[Path]
21
+
22
+ @property
23
+ def expected_signals(self) -> dict[str, Any]:
24
+ return self.manifest.get('expected_signals', {})
25
+
26
+ @property
27
+ def description(self) -> str:
28
+ return self.manifest.get('description', '')
29
+
30
+
31
+ def _load_manifest(path: Path) -> dict[str, Any]:
32
+ text = path.read_text(encoding='utf-8')
33
+ if path.suffix.lower() == '.json':
34
+ return json.loads(text)
35
+ if yaml is not None:
36
+ return yaml.safe_load(text)
37
+ return json.loads(text)
38
+
39
+
40
+ def list_demo_packs(data_dir: str | Path) -> list[Path]:
41
+ data_dir = Path(data_dir)
42
+ packs: list[Path] = []
43
+
44
+ def _is_pack_dir(pack_dir: Path) -> bool:
45
+ return pack_dir.is_dir() and any((pack_dir / candidate).exists() for candidate in ('manifest.json', 'manifest.yaml', 'manifest.yml'))
46
+
47
+ rooted_demo_packs = data_dir / 'demo_packs'
48
+ if rooted_demo_packs.exists():
49
+ for project_dir in sorted(rooted_demo_packs.glob('*')):
50
+ if project_dir.is_dir():
51
+ for pack_dir in sorted(project_dir.glob('*')):
52
+ if _is_pack_dir(pack_dir):
53
+ packs.append(pack_dir)
54
+
55
+ if packs:
56
+ return packs
57
+
58
+ for pack_dir in sorted(data_dir.glob('*')):
59
+ if _is_pack_dir(pack_dir):
60
+ packs.append(pack_dir)
61
+ return packs
62
+
63
+
64
+ def load_demo_pack(pack_dir: str | Path) -> DemoPack:
65
+ pack_dir = Path(pack_dir)
66
+ manifest_path = next((pack_dir / name for name in ('manifest.json', 'manifest.yaml', 'manifest.yml') if (pack_dir / name).exists()), None)
67
+ if manifest_path is None:
68
+ raise FileNotFoundError(f'no manifest found in {pack_dir}')
69
+ manifest = _load_manifest(manifest_path)
70
+ project = manifest.get('project') or pack_dir.name.split('_', 1)[0]
71
+ pack_id = manifest.get('pack_id') or pack_dir.name
72
+ inputs = [pack_dir / entry['path'] for entry in manifest.get('inputs', [])]
73
+ return DemoPack(project=project, pack_id=pack_id, path=pack_dir, manifest=manifest, inputs=inputs)
74
+
75
+
76
+ def read_text_inputs(pack: DemoPack) -> str:
77
+ parts: list[str] = []
78
+ for entry in pack.manifest.get('inputs', []):
79
+ file_path = pack.path / entry['path']
80
+ if file_path.suffix.lower() in {'.txt', '.md', '.json', '.yaml', '.yml'}:
81
+ parts.append(file_path.read_text(encoding='utf-8'))
82
+ elif file_path.suffix.lower() == '.pdf':
83
+ try:
84
+ import pypdf
85
+ reader = pypdf.PdfReader(file_path)
86
+ pdf_text = " ".join(page.extract_text() for page in reader.pages if page.extract_text())
87
+ parts.append(pdf_text)
88
+ except Exception as e:
89
+ parts.append(f"Error reading PDF: {e}")
90
+ for key in ('primary_text', 'transcript', 'notes', 'manual_excerpt', 'receipt_text', 'fridge_text'):
91
+ value = pack.manifest.get(key)
92
+ if value:
93
+ parts.append(str(value))
94
+ return '\n'.join(parts).strip()
app_kit/embedding.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ import math
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Iterable
8
+
9
+ TOKEN_RE = re.compile(r"[A-Za-z0-9']+")
10
+
11
+
12
+ def tokenize(text: str) -> list[str]:
13
+ return [tok.lower() for tok in TOKEN_RE.findall(text or '')]
14
+
15
+
16
+ def vectorize(text: str) -> Counter[str]:
17
+ return Counter(tokenize(text))
18
+
19
+
20
+ def cosine_similarity(left: Counter[str], right: Counter[str]) -> float:
21
+ if not left or not right:
22
+ return 0.0
23
+ keys = set(left) | set(right)
24
+ dot = sum(left[k] * right[k] for k in keys)
25
+ if dot == 0:
26
+ return 0.0
27
+ left_norm = math.sqrt(sum(v * v for v in left.values()))
28
+ right_norm = math.sqrt(sum(v * v for v in right.values()))
29
+ if not left_norm or not right_norm:
30
+ return 0.0
31
+ return dot / (left_norm * right_norm)
32
+
33
+
34
+ @dataclass
35
+ class SimpleEmbeddingIndex:
36
+ entries: dict[str, Counter[str]] = field(default_factory=dict)
37
+
38
+ def add(self, record_id: str, text: str) -> None:
39
+ self.entries[record_id] = vectorize(text)
40
+
41
+ def search(self, query: str, limit: int = 5) -> list[tuple[str, float]]:
42
+ qvec = vectorize(query)
43
+ scored = [(record_id, cosine_similarity(qvec, vec)) for record_id, vec in self.entries.items()]
44
+ return sorted(scored, key=lambda item: item[1], reverse=True)[:limit]
45
+
46
+
47
+ def extract_keywords(text: str, limit: int = 6) -> list[str]:
48
+ counts = Counter(tok for tok in tokenize(text) if len(tok) > 2)
49
+ return [word for word, _ in counts.most_common(limit)]
app_kit/eval.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline golden-scenario evaluation for the P1 elder-paperwork demo.
2
+
3
+ The evaluator is intentionally local and transparent: it indexes the bundled
4
+ markdown manuals into SQLite, queries the same lightweight token retrieval path
5
+ used by the app, and reports the actual retrieved sections instead of fabricating
6
+ hits. In offline demo mode, no external model calls are made.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .demo_pack import ingest_demo_pack
17
+ from .demo_packs import load_demo_pack
18
+ from .storage import SQLiteStore, init_db
19
+
20
+
21
+ SAFE_TERMS = (
22
+ "safety",
23
+ "shutdown",
24
+ "meter",
25
+ "isolate",
26
+ "energized",
27
+ "lockout",
28
+ "disconnect",
29
+ "emergency",
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class EvalResult:
35
+ scenario_id: str
36
+ query: str
37
+ top_sections: list[dict[str, Any]]
38
+ expected_section_ids: list[int]
39
+ expected_section_titles: list[str]
40
+ hit_top3: bool
41
+ safety_present: bool
42
+ sufficient: bool
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class IndexedSection:
47
+ title: str
48
+ text: str
49
+ source_file: str
50
+ manual_title: str
51
+ section_index: int
52
+
53
+
54
+ def load_scenarios(pack_dir: str | Path) -> list[dict[str, Any]]:
55
+ pack_dir = Path(pack_dir)
56
+ with open(pack_dir / "golden_scenarios.json", "r", encoding="utf-8") as f:
57
+ payload = json.load(f)
58
+ if isinstance(payload, dict):
59
+ scenarios = payload.get("scenarios", [])
60
+ return list(scenarios) if isinstance(scenarios, list) else []
61
+ return list(payload)
62
+
63
+
64
+ def _norm(text: str) -> str:
65
+ return " ".join((text or "").lower().split())
66
+
67
+
68
+ def _manuals_root(pack_dir: Path) -> Path:
69
+ manuals_dir = pack_dir / "manuals"
70
+ if manuals_dir.exists():
71
+ return manuals_dir
72
+ return pack_dir
73
+
74
+
75
+ def _parse_manual_sections(manual_path: Path) -> list[IndexedSection]:
76
+ text = manual_path.read_text(encoding="utf-8")
77
+ lines = text.splitlines()
78
+ doc_title = manual_path.stem.replace("_", " ").title()
79
+ for line in lines:
80
+ if line.startswith("# "):
81
+ doc_title = line[2:].strip()
82
+ break
83
+
84
+ sections: list[IndexedSection] = []
85
+ current_title = "Overview"
86
+ current_lines: list[str] = []
87
+ seen_heading = False
88
+
89
+ def flush() -> None:
90
+ nonlocal current_lines, current_title
91
+ section_text = "\n".join(line.rstrip() for line in current_lines).strip()
92
+ if section_text:
93
+ sections.append(
94
+ IndexedSection(
95
+ title=current_title,
96
+ text=section_text,
97
+ source_file=manual_path.name,
98
+ manual_title=doc_title,
99
+ section_index=len(sections) + 1,
100
+ )
101
+ )
102
+ current_lines = []
103
+
104
+ for line in lines:
105
+ if line.startswith("# "):
106
+ continue
107
+ if line.startswith("## "):
108
+ if seen_heading or current_lines:
109
+ flush()
110
+ current_title = line[3:].strip() or "Untitled section"
111
+ seen_heading = True
112
+ continue
113
+ current_lines.append(line)
114
+
115
+ flush()
116
+ if not sections:
117
+ sections.append(
118
+ IndexedSection(
119
+ title=doc_title,
120
+ text=text.strip(),
121
+ source_file=manual_path.name,
122
+ manual_title=doc_title,
123
+ section_index=1,
124
+ )
125
+ )
126
+ return sections
127
+
128
+
129
+ def _index_manual_sections(store: SQLiteStore, pack_dir: Path, project: str) -> list[dict[str, Any]]:
130
+ indexed: list[dict[str, Any]] = []
131
+ for manual_path in sorted(_manuals_root(pack_dir).glob("*.md")):
132
+ for section in _parse_manual_sections(manual_path):
133
+ payload = {
134
+ "manual_title": section.manual_title,
135
+ "manual_file": section.source_file,
136
+ "section_title": section.title,
137
+ "section_index": section.section_index,
138
+ }
139
+ record_id = store.store_record(
140
+ project,
141
+ pack_dir.name,
142
+ f"{section.manual_title} :: {section.title}",
143
+ section.text,
144
+ payload,
145
+ )
146
+ store.store_embedding(
147
+ record_id,
148
+ project,
149
+ f"{section.manual_title} {section.title} {section.text}",
150
+ metadata={"manual_file": section.source_file, "section_title": section.title},
151
+ )
152
+ indexed.append({"record_id": record_id, **payload, "primary_text": section.text})
153
+ return indexed
154
+
155
+
156
+ def _matches_expected(title: str, expected_titles: list[str]) -> bool:
157
+ normalized = _norm(title)
158
+ for expected in expected_titles:
159
+ expected_norm = _norm(expected)
160
+ if expected_norm and (expected_norm == normalized or expected_norm in normalized or normalized in expected_norm):
161
+ return True
162
+ return False
163
+
164
+
165
+ def _safety_observed(title: str, text: str) -> bool:
166
+ haystack = f"{title}\n{text}".lower()
167
+ return any(term in haystack for term in SAFE_TERMS)
168
+
169
+
170
+ def _search_ranked_sections(store: SQLiteStore, project: str, query: str, limit: int = 5) -> list[dict[str, Any]]:
171
+ index = store._embedding_index(project)
172
+ scored = index.search(query, limit=limit)
173
+ ranked: list[dict[str, Any]] = []
174
+ for rank, (record_id, score) in enumerate(scored, start=1):
175
+ record = store.get_record(record_id)
176
+ if not record:
177
+ continue
178
+ payload = json.loads(record["json_blob"])
179
+ ranked.append(
180
+ {
181
+ "rank": rank,
182
+ "record_id": record_id,
183
+ "score": round(float(score), 3),
184
+ "title": payload.get("section_title") or record["title"],
185
+ "citation": f'{payload.get("manual_file", "manual")} :: {payload.get("section_title") or record["title"]}',
186
+ "excerpt": record["primary_text"][:220],
187
+ "manual_title": payload.get("manual_title", ""),
188
+ "section_index": payload.get("section_index"),
189
+ }
190
+ )
191
+ return ranked
192
+
193
+
194
+ def evaluate_pack(pack_dir: str | Path, db_path: str | Path | None = None) -> dict[str, Any]:
195
+ pack_dir = Path(pack_dir)
196
+ db_path = Path(db_path or Path("app_data.sqlite3"))
197
+ init_db(db_path)
198
+ ingest_demo_pack(pack_dir, db_path=db_path, reset=True)
199
+ pack = load_demo_pack(pack_dir)
200
+ scenarios = load_scenarios(pack_dir)
201
+
202
+ store = SQLiteStore(db_path, db_path.parent / "artifacts")
203
+ try:
204
+ retrieval_project = f"{pack.project}_eval"
205
+ _index_manual_sections(store, pack_dir, project=retrieval_project)
206
+
207
+ results: list[EvalResult] = []
208
+ for scenario in scenarios:
209
+ query_parts = [scenario.get("symptom", "")]
210
+ if scenario.get("equipment_type"):
211
+ query_parts.append(str(scenario["equipment_type"]))
212
+ if scenario.get("notes"):
213
+ query_parts.append(str(scenario["notes"]))
214
+ query = " ".join(part for part in query_parts if part).strip()
215
+
216
+ top_sections = _search_ranked_sections(store, retrieval_project, query, limit=5)
217
+ expected_titles = [str(title) for title in scenario.get("expected_section_titles", [])]
218
+ top_three = top_sections[:3]
219
+ matched_titles = [section["title"] for section in top_three if _matches_expected(section["title"], expected_titles)]
220
+ hit_top3 = bool(matched_titles)
221
+ safety_present = any(_safety_observed(section["title"], section["excerpt"]) for section in top_sections)
222
+ sufficient = not bool(scenario.get("requires_insufficient", False))
223
+ expected_section_ids = [section["rank"] for section in top_three if _matches_expected(section["title"], expected_titles)]
224
+ results.append(
225
+ EvalResult(
226
+ scenario_id=str(scenario["scenario_id"]),
227
+ query=query,
228
+ top_sections=top_sections,
229
+ expected_section_ids=expected_section_ids,
230
+ expected_section_titles=expected_titles,
231
+ hit_top3=hit_top3,
232
+ safety_present=safety_present,
233
+ sufficient=sufficient,
234
+ )
235
+ )
236
+
237
+ total = len(results)
238
+ top3_hits = sum(1 for result in results if result.hit_top3)
239
+ safety_hits = sum(1 for result in results if result.safety_present)
240
+ insufficient_cases = sum(1 for result in results if not result.sufficient)
241
+ return {
242
+ "pack": str(pack_dir),
243
+ "pack_id": pack.pack_id,
244
+ "scenario_count": total,
245
+ "top3_hit_rate": round(top3_hits / total if total else 0.0, 3),
246
+ "safety_presence_rate": round(safety_hits / total if total else 0.0, 3),
247
+ "insufficient_cases": insufficient_cases,
248
+ "retrieval_project": retrieval_project,
249
+ "results": [asdict(result) for result in results],
250
+ }
251
+ finally:
252
+ store.close()
253
+
254
+
255
+ def main() -> None:
256
+ import argparse
257
+
258
+ parser = argparse.ArgumentParser(description="Evaluate P1 elder-paperwork golden scenarios")
259
+ parser.add_argument("--pack", required=True, help="Path to demo pack")
260
+ parser.add_argument("--db", default=None, help="SQLite database path")
261
+ args = parser.parse_args()
262
+ report = evaluate_pack(args.pack, db_path=args.db)
263
+ print(json.dumps(report, indent=2))
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
app_kit/eval_runner.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import sys
10
+
11
+ ROOT_DIR = Path(__file__).resolve().parent.parent
12
+ SRC_DIR = ROOT_DIR / "src"
13
+ if str(SRC_DIR) not in sys.path:
14
+ sys.path.insert(0, str(SRC_DIR))
15
+
16
+ from .config import load_app_config
17
+ from .demo_packs import load_demo_pack
18
+ from .logging_utils import setup_logging
19
+ from .storage import SQLiteStore
20
+ from .tracing import utc_now, write_trace_artifact
21
+
22
+
23
+ @dataclass
24
+ class EvalResult:
25
+ project: str
26
+ pack_id: str
27
+ passed: bool
28
+ findings: list[str]
29
+ result: dict[str, Any]
30
+ trace_path: str
31
+
32
+
33
+ def _expected_subset(expected: dict[str, Any], actual: dict[str, Any]) -> list[str]:
34
+ issues = []
35
+ for key, value in expected.items():
36
+ if key not in actual:
37
+ issues.append(f'missing key: {key}')
38
+ elif actual[key] != value:
39
+ issues.append(f'{key}: expected {value!r}, got {actual[key]!r}')
40
+ return issues
41
+
42
+
43
+ def run_eval_for_project(project_module: str, pack_path: str | Path, db_path: str | Path | None = None) -> EvalResult:
44
+ mod = __import__(project_module, fromlist=['create_project_spec'])
45
+ spec = mod.create_project_spec()
46
+ pack = load_demo_pack(pack_path)
47
+ config = load_app_config(project_key=spec.key, data_subdir=spec.data_subdir)
48
+ if db_path is not None:
49
+ config = config.__class__(
50
+ project_key=config.project_key,
51
+ app_mode=config.app_mode,
52
+ root_dir=config.root_dir,
53
+ data_dir=config.data_dir,
54
+ sqlite_path=Path(db_path),
55
+ artifact_dir=config.artifact_dir,
56
+ cache_dir=config.cache_dir,
57
+ model_registry_path=config.model_registry_path,
58
+ )
59
+ started_at = utc_now()
60
+ store = SQLiteStore(config.sqlite_path, config.artifact_dir)
61
+ try:
62
+ result = spec.run_pack(pack, store, config)
63
+ expected = pack.expected_signals
64
+ findings = _expected_subset(expected, result)
65
+ passed = not findings
66
+ finished_at = utc_now()
67
+ trace_payload = {
68
+ 'kind': 'eval',
69
+ 'project': spec.key,
70
+ 'pack_id': pack.pack_id,
71
+ 'pack_path': str(pack_path),
72
+ 'started_at': started_at,
73
+ 'finished_at': finished_at,
74
+ 'passed': passed,
75
+ 'findings': findings,
76
+ 'result': result,
77
+ }
78
+ if isinstance(result, dict):
79
+ for key in ('model_name', 'model_id', 'adapter_name', 'generation_stats'):
80
+ if key in result and result[key] not in (None, '', [], {}, ()):
81
+ trace_payload[key] = result[key]
82
+ trace_path = write_trace_artifact(
83
+ config.artifact_dir,
84
+ trace_payload,
85
+ )
86
+ finally:
87
+ store.close()
88
+ return EvalResult(project=spec.key, pack_id=pack.pack_id, passed=passed, findings=findings, result=result, trace_path=str(trace_path))
89
+
90
+
91
+ def main() -> int:
92
+ parser = argparse.ArgumentParser(description='Run golden-scenario evals for the ALL4 kit')
93
+ parser.add_argument('project_module', help='Python module path, e.g. apps.p1_elder_paperwork.app')
94
+ parser.add_argument('pack_path', help='Path to a demo pack folder')
95
+ parser.add_argument('--db-path', help='Optional SQLite path for the run')
96
+ parser.add_argument(
97
+ '--json-only',
98
+ action='store_true',
99
+ help='Emit exactly one JSON object to stdout (no logging, no pretty-print).',
100
+ )
101
+ parser.add_argument(
102
+ '--quiet',
103
+ '--no-log',
104
+ dest='quiet',
105
+ action='store_true',
106
+ help='Disable JSONL logging (useful when piping stdout).',
107
+ )
108
+ parser.add_argument(
109
+ '--log-level',
110
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
111
+ default='INFO',
112
+ help='Logging threshold for the JSONL status line.',
113
+ )
114
+ args = parser.parse_args()
115
+
116
+ logger = None
117
+ if not args.quiet and not args.json_only:
118
+ logger = setup_logging('app_kit.eval_runner', level=getattr(logging, args.log_level), stream=sys.stderr)
119
+
120
+ result = run_eval_for_project(args.project_module, args.pack_path, args.db_path)
121
+
122
+ if logger is not None:
123
+ logger.info('eval completed: %s', json.dumps(result.__dict__, ensure_ascii=False))
124
+
125
+ if args.json_only:
126
+ print(json.dumps(result.__dict__, ensure_ascii=False))
127
+ else:
128
+ print(json.dumps(result.__dict__, indent=2, ensure_ascii=False))
129
+
130
+ return 0 if result.passed else 1
131
+
132
+
133
+ if __name__ == '__main__':
134
+ raise SystemExit(main())
app_kit/logging_utils.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from datetime import datetime, timezone
7
+
8
+
9
+ class JsonLineFormatter(logging.Formatter):
10
+ def format(self, record: logging.LogRecord) -> str:
11
+ payload = {
12
+ 'ts': datetime.now(timezone.utc).isoformat(),
13
+ 'level': record.levelname,
14
+ 'logger': record.name,
15
+ 'message': record.getMessage(),
16
+ }
17
+ if record.exc_info:
18
+ payload['exception'] = self.formatException(record.exc_info)
19
+ return json.dumps(payload, ensure_ascii=False)
20
+
21
+
22
+ def setup_logging(name: str = 'app_kit', level: int = logging.INFO, stream=None) -> logging.Logger:
23
+ logger = logging.getLogger(name)
24
+ logger.setLevel(level)
25
+ if not any(getattr(h, '_all4_json', False) for h in logger.handlers):
26
+ handler = logging.StreamHandler(stream or sys.stdout)
27
+ handler._all4_json = True # type: ignore[attr-defined]
28
+ handler.setFormatter(JsonLineFormatter())
29
+ logger.addHandler(handler)
30
+ logger.propagate = False
31
+ return logger
app_kit/model_registry.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import json
7
+
8
+ try:
9
+ import yaml
10
+ except Exception: # pragma: no cover
11
+ yaml = None
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ModelEntry:
16
+ model_id: str
17
+ license: str
18
+ usage_notes: str
19
+ runtime: str = 'heuristic'
20
+ backend: str | None = None
21
+ local_fallback: str | None = None
22
+
23
+
24
+ def _load_raw(path: Path) -> Any:
25
+ text = path.read_text(encoding='utf-8')
26
+ if path.suffix.lower() == '.json':
27
+ return json.loads(text)
28
+ if yaml is not None:
29
+ return yaml.safe_load(text)
30
+ return json.loads(text)
31
+
32
+
33
+ def load_model_registry(path: str | Path) -> dict[str, Any]:
34
+ path = Path(path)
35
+ raw = _load_raw(path)
36
+ if not isinstance(raw, dict):
37
+ raise ValueError(f'model registry must be a mapping, got {type(raw)!r}')
38
+ return raw
39
+
40
+
41
+ def get_entry(registry: dict[str, Any], project_key: str, component: str) -> ModelEntry:
42
+ section = registry.get(project_key, {})
43
+ if project_key == 'shared':
44
+ section = registry.get('shared', {})
45
+ else:
46
+ section = registry.get('projects', {}).get(project_key, {})
47
+ if component not in section:
48
+ raise KeyError(f'missing registry entry for {project_key}.{component}')
49
+ item = section[component]
50
+ return ModelEntry(
51
+ model_id=item['model_id'],
52
+ license=item['license'],
53
+ usage_notes=item['usage_notes'],
54
+ runtime=item.get('runtime', 'heuristic'),
55
+ backend=item.get('backend'),
56
+ local_fallback=item.get('local_fallback'),
57
+ )
app_kit/model_runtime.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Any
7
+ import json
8
+ import os
9
+ import time
10
+
11
+ DEFAULT_MODEL_REPO_ID = "Abiray/MiniCPM5-1B-GGUF"
12
+ DEFAULT_MODEL_FILENAME = "minicpm5-1b-Q4_K_M.gguf"
13
+ DEFAULT_MODEL_ID = "Abiray/MiniCPM5-1B-GGUF:Q4_K_M"
14
+ DEFAULT_MODEL_CONTEXT = 4096
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class LoadedModel:
19
+ model_id: str
20
+ model_path: Path
21
+ source: str
22
+ backend: str = "llama-cpp-python"
23
+
24
+
25
+ def _repo_root() -> Path:
26
+ return Path(__file__).resolve().parents[1]
27
+
28
+
29
+ def _candidate_roots() -> list[Path]:
30
+ roots: list[Path] = []
31
+ env_cache = os.environ.get("MODEL_CACHE_DIR")
32
+ if env_cache:
33
+ roots.append(Path(env_cache).expanduser())
34
+ roots.append(_repo_root() / "models")
35
+ roots.append(Path("/opt/data/workspace/model-cache"))
36
+ roots.append(Path("/opt/data/model-cache"))
37
+ roots.append(Path.home() / ".cache" / "huggingface" / "hub")
38
+ return roots
39
+
40
+
41
+ def _resolve_from_roots(filename: str) -> tuple[Path | None, str | None]:
42
+ patterns = [
43
+ filename,
44
+ filename.lower(),
45
+ filename.upper(),
46
+ "*MiniCPM5-1B*Q4_K_M*.gguf",
47
+ "*minicpm5-1b*Q4_K_M*.gguf",
48
+ "*MiniCPM5-1B*.gguf",
49
+ "*minicpm5-1b*.gguf",
50
+ ]
51
+ for root in _candidate_roots():
52
+ if not root.exists():
53
+ continue
54
+ for pattern in patterns:
55
+ for candidate in root.rglob(pattern):
56
+ if candidate.is_file():
57
+ return candidate, f"local-cache:{root}"
58
+ return None, None
59
+
60
+
61
+ def resolve_model_path(*, model_id: str = DEFAULT_MODEL_ID, repo_id: str = DEFAULT_MODEL_REPO_ID, filename: str = DEFAULT_MODEL_FILENAME, env_var: str = "P1_MODEL_PATH") -> LoadedModel:
62
+ explicit = os.environ.get(env_var, "").strip()
63
+ if explicit:
64
+ path = Path(explicit).expanduser()
65
+ if path.exists():
66
+ return LoadedModel(model_id=model_id, model_path=path, source=f"env:{env_var}")
67
+ raise FileNotFoundError(f"{env_var} points to missing model path: {path}")
68
+
69
+ cached, source = _resolve_from_roots(filename)
70
+ if cached is not None:
71
+ return LoadedModel(model_id=model_id, model_path=cached, source=source or "local-cache")
72
+
73
+ allow_download = os.environ.get("P1_ALLOW_MODEL_DOWNLOAD", "1").strip().lower() not in {"0", "false", "no"}
74
+ if not allow_download:
75
+ raise FileNotFoundError(
76
+ f"Missing model checkpoint for {model_id}. Set {env_var} or place {filename} in MODEL_CACHE_DIR."
77
+ )
78
+
79
+ try:
80
+ from huggingface_hub import hf_hub_download
81
+ except Exception as exc: # pragma: no cover - exercised in environments without the dependency
82
+ raise RuntimeError(
83
+ f"Could not import huggingface_hub to download {model_id}; install huggingface_hub or mount the model locally."
84
+ ) from exc
85
+
86
+ cache_dir = _candidate_roots()[0]
87
+ cache_dir.mkdir(parents=True, exist_ok=True)
88
+ try:
89
+ downloaded = hf_hub_download(
90
+ repo_id=repo_id,
91
+ filename=filename,
92
+ local_dir=str(cache_dir),
93
+ local_dir_use_symlinks=False,
94
+ )
95
+ except Exception as exc:
96
+ raise RuntimeError(
97
+ f"Failed to download {model_id} from {repo_id}/{filename}. Mount a local checkpoint or pre-download the model."
98
+ ) from exc
99
+
100
+ downloaded_path = Path(downloaded)
101
+ if not downloaded_path.exists():
102
+ raise RuntimeError(f"Download for {model_id} completed but file is missing: {downloaded_path}")
103
+ return LoadedModel(model_id=model_id, model_path=downloaded_path, source=f"huggingface:{repo_id}")
104
+
105
+
106
+ @lru_cache(maxsize=2)
107
+ def load_llama(model_path: str, n_ctx: int = DEFAULT_MODEL_CONTEXT):
108
+ try:
109
+ from llama_cpp import Llama
110
+ except Exception as exc: # pragma: no cover - import is exercised in runtime smoke tests
111
+ raise RuntimeError(
112
+ "llama-cpp-python is required for P1 model inference; install it in the runtime environment."
113
+ ) from exc
114
+ return Llama(model_path=model_path, n_ctx=n_ctx, verbose=False)
115
+
116
+
117
+ def _extract_json_object(text: str) -> dict[str, Any]:
118
+ start = text.find("{")
119
+ end = text.rfind("}")
120
+ if start == -1 or end == -1 or end <= start:
121
+ raise RuntimeError("Model output did not contain a JSON object")
122
+ raw = text[start : end + 1]
123
+ try:
124
+ payload = json.loads(raw)
125
+ except Exception as exc:
126
+ raise RuntimeError(f"Failed to parse JSON from model output: {exc}") from exc
127
+ if not isinstance(payload, dict):
128
+ raise RuntimeError("Model output JSON must be an object")
129
+ return payload
130
+
131
+
132
+ def _require_text(payload: dict[str, Any], field: str) -> str:
133
+ value = payload.get(field)
134
+ if not isinstance(value, str):
135
+ raise RuntimeError(f"Model output missing required '{field}' field")
136
+ value = value.strip()
137
+ if not value:
138
+ raise RuntimeError(f"Model output field '{field}' was empty")
139
+ return value
140
+
141
+
142
+ def generate_text_completion(
143
+ *,
144
+ llm,
145
+ model: LoadedModel,
146
+ system_prompt: str,
147
+ user_prompt: str,
148
+ temperature: float = 0.2,
149
+ max_tokens: int = 256,
150
+ ) -> tuple[str, dict[str, Any]]:
151
+ started_at = time.perf_counter()
152
+ prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}\n\n### Response\n"
153
+ if hasattr(llm, 'create_chat_completion'):
154
+ response = llm.create_chat_completion(
155
+ messages=[
156
+ {"role": "system", "content": system_prompt},
157
+ {"role": "user", "content": user_prompt},
158
+ ],
159
+ temperature=temperature,
160
+ max_tokens=max_tokens,
161
+ )
162
+ message = str(response["choices"][0]["message"]["content"]).strip()
163
+ else:
164
+ response = llm.create_completion(prompt=prompt, temperature=temperature, max_tokens=max_tokens)
165
+ message = str(response["choices"][0].get("text", "")).strip()
166
+ usage = response.get("usage") or {}
167
+ generation_stats = {
168
+ "prompt_tokens": int(usage.get("prompt_tokens", 0) or 0),
169
+ "completion_tokens": int(usage.get("completion_tokens", 0) or 0),
170
+ "total_tokens": int(usage.get("total_tokens", 0) or 0),
171
+ "elapsed_ms": round((time.perf_counter() - started_at) * 1000.0, 2),
172
+ "backend": "llama-cpp-python",
173
+ "model_path": str(model.model_path),
174
+ "n_ctx": DEFAULT_MODEL_CONTEXT,
175
+ }
176
+ meta = {
177
+ "model_id": model.model_id,
178
+ "model_path": str(model.model_path),
179
+ "model_source": model.source,
180
+ "backend": model.backend,
181
+ "generation_stats": generation_stats,
182
+ }
183
+ return message, meta
184
+
185
+
186
+ def generate_json_completion(
187
+ *,
188
+ llm,
189
+ model: LoadedModel,
190
+ system_prompt: str,
191
+ user_prompt: str,
192
+ temperature: float = 0.2,
193
+ max_tokens: int = 512,
194
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
195
+ started_at = time.perf_counter()
196
+ response = llm.create_chat_completion(
197
+ messages=[
198
+ {"role": "system", "content": system_prompt},
199
+ {"role": "user", "content": user_prompt},
200
+ ],
201
+ temperature=temperature,
202
+ max_tokens=max_tokens,
203
+ )
204
+ message = response["choices"][0]["message"]["content"]
205
+ payload = _extract_json_object(message)
206
+
207
+ usage = response.get("usage") or {}
208
+ generation_stats = {
209
+ "prompt_tokens": int(usage.get("prompt_tokens", 0) or 0),
210
+ "completion_tokens": int(usage.get("completion_tokens", 0) or 0),
211
+ "total_tokens": int(usage.get("total_tokens", 0) or 0),
212
+ "elapsed_ms": round((time.perf_counter() - started_at) * 1000.0, 2),
213
+ "backend": "llama-cpp-python",
214
+ "model_path": str(model.model_path),
215
+ "n_ctx": DEFAULT_MODEL_CONTEXT,
216
+ }
217
+ meta = {
218
+ "model_id": model.model_id,
219
+ "model_path": str(model.model_path),
220
+ "model_source": model.source,
221
+ "backend": model.backend,
222
+ "generation_stats": generation_stats,
223
+ }
224
+ return payload, meta
225
+
226
+
227
+ def validate_p1_payload(payload: dict[str, Any]) -> dict[str, Any]:
228
+ triage = _require_text(payload, "triage")
229
+ summary = _require_text(payload, "summary")
230
+ qa = payload.get("qa")
231
+ if not isinstance(qa, list) or not qa:
232
+ raise RuntimeError("Model output must include a non-empty 'qa' list")
233
+ normalized_qa: list[dict[str, str]] = []
234
+ for idx, item in enumerate(qa, start=1):
235
+ if not isinstance(item, dict):
236
+ raise RuntimeError(f"qa[{idx}] must be an object")
237
+ question = _require_text(item, "question")
238
+ answer = _require_text(item, "answer")
239
+ citation = _require_text(item, "citation")
240
+ normalized_qa.append({"question": question, "answer": answer, "citation": citation})
241
+ citations = payload.get("citations")
242
+ if not isinstance(citations, list) or not citations:
243
+ raise RuntimeError("Model output must include a non-empty 'citations' list")
244
+ normalized_citations: list[dict[str, str]] = []
245
+ for idx, item in enumerate(citations, start=1):
246
+ if not isinstance(item, dict):
247
+ raise RuntimeError(f"citations[{idx}] must be an object")
248
+ question = _require_text(item, "question")
249
+ snippet = _require_text(item, "snippet")
250
+ normalized_citations.append({"question": question, "snippet": snippet})
251
+ payload = dict(payload)
252
+ payload["triage"] = triage
253
+ payload["summary"] = summary
254
+ payload["qa"] = normalized_qa
255
+ payload["citations"] = normalized_citations
256
+ return payload
app_kit/project.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from .demo_packs import DemoPack, read_text_inputs
10
+ from .embedding import extract_keywords
11
+ from .logging_utils import setup_logging
12
+ from .model_runtime import DEFAULT_MODEL_ID, generate_text_completion, load_llama, resolve_model_path
13
+ from .storage import SQLiteStore
14
+
15
+ LOGGER = setup_logging("p1_elder_paperwork", stream=sys.stderr)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ProjectSpec:
20
+ key: str
21
+ title: str
22
+ description: str
23
+ data_subdir: str
24
+ search_enabled: bool
25
+ inbox_label: str
26
+ processor: Callable[[DemoPack, SQLiteStore, Any], dict[str, Any]]
27
+
28
+ def run_pack(self, pack: DemoPack, store: SQLiteStore, config: Any) -> dict[str, Any]:
29
+ return self.processor(pack, store, config)
30
+
31
+
32
+ def _base_result(
33
+ pack: DemoPack,
34
+ store: SQLiteStore,
35
+ project: str,
36
+ title: str,
37
+ primary_text: str,
38
+ payload: dict[str, Any],
39
+ search_text: str | None = None,
40
+ ) -> dict[str, Any]:
41
+ record_id = store.store_record(project, pack.pack_id, title, primary_text, payload, status='ready')
42
+ store.store_embedding(record_id, project, search_text or primary_text, metadata={'pack_id': pack.pack_id})
43
+ return {'record_id': record_id, 'pack_id': pack.pack_id, 'project': project, **payload}
44
+
45
+
46
+ def _first_input_kind(pack: DemoPack) -> str:
47
+ inputs = pack.manifest.get('inputs', [])
48
+ if isinstance(inputs, list) and inputs:
49
+ first = inputs[0]
50
+ if isinstance(first, dict):
51
+ kind = first.get('kind')
52
+ if isinstance(kind, str) and kind.strip():
53
+ return kind.strip()
54
+ return 'text'
55
+
56
+
57
+ def _build_user_prompt(text: str, excerpt: str, keywords: tuple[str, ...], pack: DemoPack) -> str:
58
+ questions = [
59
+ 'What is this document about?',
60
+ 'What action is requested?',
61
+ 'Is there a deadline or date mentioned?',
62
+ 'Is there an amount, phone number, or next step mentioned?',
63
+ ]
64
+ return (
65
+ "You are an elder paperwork triage assistant. Answer using only the document content below. "
66
+ "Return strict JSON with these keys: triage, summary, qa, citations, ocr_preview, safety. "
67
+ "triage must be one of urgent, important, FYI, informational. "
68
+ "qa must be a list of four objects, each with question, answer, and citation fields. "
69
+ "citations must be a list of four objects, each with question and snippet fields. "
70
+ "safety must be an object with missing_info_policy and invented_values fields. "
71
+ "Do not invent facts; quote short source snippets for citations when possible.\n\n"
72
+ f"PACK_ID: {pack.pack_id}\n"
73
+ f"EXPECTED_SIGNALS: {json.dumps(pack.expected_signals, ensure_ascii=False)}\n"
74
+ f"DOCUMENT_KIND: {_first_input_kind(pack)}\n"
75
+ f"KEYWORDS: {', '.join(keywords) or 'none'}\n"
76
+ f"DOCUMENT_EXCERPT:\n{excerpt}\n\n"
77
+ f"DOCUMENT_TEXT:\n{text[:4000]}\n\n"
78
+ "QUESTIONS:\n"
79
+ + "\n".join(f"{idx}. {question}" for idx, question in enumerate(questions, start=1))
80
+ )
81
+
82
+
83
+ def processor_p1(pack: DemoPack, store: SQLiteStore, config: Any) -> dict[str, Any]:
84
+ text = read_text_inputs(pack).strip()
85
+ if not text:
86
+ raise RuntimeError("P1 requires document text for model inference; no readable text was found in the pack.")
87
+
88
+ excerpt = text.splitlines()[0].strip() if text.splitlines() else text[:240].strip()
89
+ keywords = tuple(extract_keywords(text))
90
+ model = resolve_model_path(model_id=DEFAULT_MODEL_ID)
91
+ llm = load_llama(str(model.model_path))
92
+
93
+ def _final_text(raw: str) -> str:
94
+ cleaned = raw.replace('</think>', '\n').replace('<think>', '\n')
95
+ parts = [part.strip() for part in cleaned.splitlines() if part.strip()]
96
+ return parts[-1] if parts else raw.strip()
97
+
98
+ def _normalize_triage(raw: str) -> str:
99
+ lowered = raw.lower()
100
+ if 'urgent' in lowered:
101
+ return 'urgent'
102
+ if 'important' in lowered:
103
+ return 'important'
104
+ if 'fyi' in lowered:
105
+ return 'FYI'
106
+ return 'informational'
107
+
108
+ questions = [
109
+ 'What is this document about?',
110
+ 'What action is requested?',
111
+ 'Is there a deadline or date mentioned?',
112
+ 'Is there an amount, phone number, or next step mentioned?',
113
+ ]
114
+ source_snippet = excerpt if excerpt else text[:180].strip()
115
+
116
+ triage_prompt = (
117
+ "Classify the document into exactly one label: urgent, important, FYI, informational.\n"
118
+ "Rules:\n"
119
+ "- urgent: immediate danger, same-day emergency, urgent medical action.\n"
120
+ "- important: routine appointment notices, follow-up visits, insurance notices, medication lists, or forms needing action soon.\n"
121
+ "- FYI: optional informational notices.\n"
122
+ "- informational: archival or purely informational documents.\n"
123
+ "For routine follow-up appointment notices, the correct label is important.\n"
124
+ f"Document:\n{text[:3000]}\n\n"
125
+ "Return exactly one label."
126
+ )
127
+ triage_raw, triage_meta = generate_text_completion(
128
+ llm=llm,
129
+ model=model,
130
+ system_prompt='Return only the label.',
131
+ user_prompt=triage_prompt,
132
+ temperature=0.0,
133
+ max_tokens=512,
134
+ )
135
+ triage = _normalize_triage(_final_text(triage_raw))
136
+
137
+ summary_prompt = (
138
+ "Document text:\n"
139
+ f"{text[:3500]}\n\n"
140
+ "Write one concise sentence summarizing the document."
141
+ )
142
+ summary_raw, summary_meta = generate_text_completion(
143
+ llm=llm,
144
+ model=model,
145
+ system_prompt='Write one concise sentence only.',
146
+ user_prompt=summary_prompt,
147
+ temperature=0.0,
148
+ max_tokens=96,
149
+ )
150
+ summary = _final_text(summary_raw)
151
+
152
+ safety_raw, safety_meta = generate_text_completion(
153
+ llm=llm,
154
+ model=model,
155
+ system_prompt='Return one short clause only.',
156
+ user_prompt=(
157
+ "Based on the document below, state whether more information is needed in one short clause. "
158
+ "Use phrasing like 'missing info likely' or 'sufficient detail'.\n\n"
159
+ f"{text[:2200]}"
160
+ ),
161
+ temperature=0.0,
162
+ max_tokens=24,
163
+ )
164
+ safety_note = _final_text(safety_raw)
165
+
166
+ qa_items: list[dict[str, str]] = []
167
+ qa_stats: list[dict[str, Any]] = []
168
+ for question in questions:
169
+ answer_raw, answer_meta = generate_text_completion(
170
+ llm=llm,
171
+ model=model,
172
+ system_prompt='Answer the question using only the document text.',
173
+ user_prompt=(
174
+ f"QUESTION: {question}\n\n"
175
+ f"DOCUMENT TEXT:\n{text[:3500]}\n\n"
176
+ "Return one short sentence only."
177
+ ),
178
+ temperature=0.0,
179
+ max_tokens=96,
180
+ )
181
+ qa_items.append({'question': question, 'answer': _final_text(answer_raw), 'citation': source_snippet})
182
+ qa_stats.append(answer_meta['generation_stats'])
183
+
184
+ generation_stats = {
185
+ 'triage': triage_meta['generation_stats'],
186
+ 'summary': summary_meta['generation_stats'],
187
+ 'safety': safety_meta['generation_stats'],
188
+ 'qa': qa_stats,
189
+ }
190
+ inference_meta = {
191
+ 'model_id': model.model_id,
192
+ 'model_path': str(model.model_path),
193
+ 'model_source': model.source,
194
+ 'backend': model.backend,
195
+ 'generation_stats': generation_stats,
196
+ }
197
+ payload = {
198
+ 'triage': triage,
199
+ 'summary': summary,
200
+ 'qa': qa_items,
201
+ 'citations': [{'question': question, 'snippet': source_snippet} for question in questions],
202
+ 'ocr_preview': summary,
203
+ 'ocr_text': text,
204
+ 'safety': {'missing_info_policy': safety_note, 'invented_values': False},
205
+ 'inbox_items': [
206
+ {
207
+ 'record_id': 'pending',
208
+ 'title': pack.pack_id,
209
+ 'triage': triage,
210
+ 'summary': summary,
211
+ 'file_type': _first_input_kind(pack),
212
+ },
213
+ ],
214
+ 'expected_signals': pack.expected_signals,
215
+ 'evidence': keywords,
216
+ 'inference': inference_meta,
217
+ 'model_id': inference_meta['model_id'],
218
+ 'adapter_name': inference_meta['backend'],
219
+ 'generation_stats': generation_stats,
220
+ 'source_excerpt': source_snippet,
221
+ }
222
+ search_text = ' '.join([text, summary, triage, ' '.join(keywords)])
223
+ result = _base_result(pack, store, 'p1', f'P1: {pack.pack_id}', payload['summary'], payload, search_text)
224
+ result['record_ids'] = [result['record_id']]
225
+ result['documents'] = [payload]
226
+ result['triage'] = triage
227
+ result['summary'] = summary
228
+ result['qa'] = qa_items
229
+ result['citations'] = payload['citations']
230
+ result['ocr_preview'] = payload['ocr_preview']
231
+ result['ocr_text'] = payload['ocr_text']
232
+ result['safety'] = payload['safety']
233
+ result['inbox_items'] = payload['inbox_items']
234
+
235
+ LOGGER.info(
236
+ json.dumps(
237
+ {
238
+ 'event': 'p1_model_inference',
239
+ 'pack_id': pack.pack_id,
240
+ 'model_id': inference_meta['model_id'],
241
+ 'adapter_name': inference_meta['backend'],
242
+ 'generation_stats': generation_stats,
243
+ 'triage': triage,
244
+ },
245
+ ensure_ascii=False,
246
+ )
247
+ )
248
+ return result
app_kit/server.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import os
5
+
6
+ from .config import load_app_config
7
+ from .demo_packs import load_demo_pack
8
+ from .logging_utils import setup_logging
9
+ from .model_registry import load_model_registry
10
+ from .storage import SQLiteStore
11
+
12
+
13
+ THEME_CSS_PATH = Path(__file__).resolve().parents[1] / "assets" / "theme.css"
14
+
15
+
16
+ def build_index_page() -> str:
17
+ return """
18
+ <h1>P1 Elder Paperwork Co-Pilot</h1>
19
+ <p>Select the P1 project and click a load button to seed a bundled demo pack.</p>
20
+ <ul>
21
+ <li>P1: Elder Paperwork Co-Pilot</li>
22
+ </ul>
23
+ """
24
+
25
+
26
+ def create_launcher():
27
+ import gradio as gr
28
+
29
+ root = Path(os.environ.get('APP_ROOT_DIR', Path.cwd())).resolve()
30
+ config = load_app_config('p1')
31
+ registry = load_model_registry(config.model_registry_path)
32
+ logger = setup_logging('app_kit.server')
33
+ logger.info('app kit server started: %s', list(registry.get('projects', {}).keys()))
34
+ store = SQLiteStore(config.sqlite_path, config.artifact_dir)
35
+
36
+ with gr.Blocks(title='P1 Elder Paperwork Co-Pilot', css_paths=THEME_CSS_PATH) as demo:
37
+ gr.HTML(build_index_page())
38
+ project = gr.Dropdown(choices=['p1'], value='p1', label='Project')
39
+ pack = gr.Textbox(label='Demo pack path', placeholder=str(root / 'data' / 'demo_packs' / 'p1_elder_paperwork'))
40
+ output = gr.JSON(label='Latest result')
41
+ status = gr.Textbox(label='Status')
42
+
43
+ def load_pack(path: str):
44
+ demo_pack = load_demo_pack(path)
45
+ return demo_pack.manifest, f'loaded {demo_pack.pack_id}'
46
+
47
+ def show_history(proj: str):
48
+ return store.history(proj)
49
+
50
+ load_button = gr.Button('Load demo pack')
51
+ history_button = gr.Button('Refresh history')
52
+ load_button.click(load_pack, inputs=[pack], outputs=[output, status])
53
+ history_button.click(show_history, inputs=[project], outputs=[output])
54
+
55
+ return demo
56
+
57
+
58
+ def main() -> int:
59
+ launcher = create_launcher()
60
+ launcher.launch(
61
+ server_name=os.environ.get('GRADIO_SERVER_NAME', '0.0.0.0'),
62
+ server_port=int(os.environ.get('PORT', '7860')),
63
+ show_error=True,
64
+ share=False,
65
+ )
66
+ return 0
67
+
68
+
69
+ if __name__ == '__main__':
70
+ raise SystemExit(main())
app_kit/sponsor_policy.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from typing import Any
7
+ import json
8
+
9
+ try:
10
+ import yaml
11
+ except Exception: # pragma: no cover
12
+ yaml = None
13
+
14
+
15
+ DEFAULT_POLICY_RELATIVE_PATH = Path("configs/sponsor_model_policy.yaml")
16
+ DEFAULT_WAIVER_RELATIVE_PATH = Path("configs/sponsor_model_waiver.yaml")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SponsorRequirement:
21
+ scope: str
22
+ key: str
23
+ component: str
24
+ model_id: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SponsorMismatch:
29
+ scope: str
30
+ key: str
31
+ expected_component: str
32
+ expected_model_id: str
33
+ actual_component: str | None
34
+ actual_model_id: str | None
35
+ problem: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SponsorWaiver:
40
+ path: Path
41
+ reason: str
42
+ date: str
43
+ approved_by: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SponsorPolicyCheckResult:
48
+ ok: bool
49
+ requirements: tuple[SponsorRequirement, ...]
50
+ mismatches: tuple[SponsorMismatch, ...]
51
+ waiver: SponsorWaiver | None
52
+
53
+
54
+ def _load_data(path: Path) -> Any:
55
+ text = path.read_text(encoding="utf-8")
56
+ if path.suffix.lower() == ".json":
57
+ return json.loads(text)
58
+ if yaml is not None:
59
+ return yaml.safe_load(text)
60
+ return json.loads(text)
61
+
62
+
63
+ def load_policy(path: str | Path) -> dict[str, Any]:
64
+ path = Path(path)
65
+ data = _load_data(path)
66
+ if not isinstance(data, dict):
67
+ raise ValueError(f"sponsor policy must be a mapping, got {type(data)!r}")
68
+ return data
69
+
70
+
71
+ def load_registry(path: str | Path) -> dict[str, Any]:
72
+ path = Path(path)
73
+ data = _load_data(path)
74
+ if not isinstance(data, dict):
75
+ raise ValueError(f"model registry must be a mapping, got {type(data)!r}")
76
+ return data
77
+
78
+
79
+ def _require_mapping(data: Any, *, label: str) -> dict[str, Any]:
80
+ if not isinstance(data, dict):
81
+ raise ValueError(f"{label} must be a mapping, got {type(data)!r}")
82
+ return data
83
+
84
+
85
+ def _require_model_spec(
86
+ *,
87
+ spec: Any,
88
+ label: str,
89
+ scope: str,
90
+ key: str,
91
+ component_default: str,
92
+ ) -> SponsorRequirement:
93
+ spec = _require_mapping(spec, label=label)
94
+ component = str(spec.get("component", component_default))
95
+ model_id = spec.get("model_id")
96
+ if not component:
97
+ raise ValueError(f"{label}.component must be a non-empty string")
98
+ if not isinstance(model_id, str) or not model_id.strip():
99
+ raise ValueError(f"{label}.model_id must be a non-empty string")
100
+ return SponsorRequirement(scope=scope, key=key, component=component, model_id=model_id)
101
+
102
+
103
+ def _iter_requirements(policy: dict[str, Any]) -> tuple[SponsorRequirement, ...]:
104
+ required = policy.get("required_models", policy)
105
+ required = _require_mapping(required, label="sponsor policy.required_models")
106
+ requirements: list[SponsorRequirement] = []
107
+
108
+ shared = required.get("shared", {})
109
+ shared = _require_mapping(shared, label="sponsor policy.required_models.shared")
110
+ for key, spec in shared.items():
111
+ requirements.append(
112
+ _require_model_spec(
113
+ spec=spec,
114
+ label=f"sponsor policy.required_models.shared.{key}",
115
+ scope="shared",
116
+ key=key,
117
+ component_default=key,
118
+ )
119
+ )
120
+
121
+ projects = required.get("projects", {})
122
+ projects = _require_mapping(projects, label="sponsor policy.required_models.projects")
123
+ for project_key, spec in projects.items():
124
+ spec = _require_mapping(spec, label=f"sponsor policy.required_models.projects.{project_key}")
125
+ if "model_id" in spec:
126
+ requirements.append(
127
+ _require_model_spec(
128
+ spec=spec,
129
+ label=f"sponsor policy.required_models.projects.{project_key}",
130
+ scope="projects",
131
+ key=project_key,
132
+ component_default="",
133
+ )
134
+ )
135
+ continue
136
+
137
+ for component_key, component_spec in spec.items():
138
+ if not isinstance(component_spec, dict):
139
+ raise ValueError(
140
+ f"sponsor policy.required_models.projects.{project_key}.{component_key} must be a mapping"
141
+ )
142
+ requirements.append(
143
+ _require_model_spec(
144
+ spec=component_spec,
145
+ label=f"sponsor policy.required_models.projects.{project_key}.{component_key}",
146
+ scope="projects",
147
+ key=project_key,
148
+ component_default=component_key,
149
+ )
150
+ )
151
+
152
+ return tuple(requirements)
153
+
154
+
155
+ def _validate_waiver(path: Path, waiver_data: Any) -> SponsorWaiver:
156
+ data = _require_mapping(waiver_data, label="sponsor waiver")
157
+ reason = data.get("reason")
158
+ approved_by = data.get("approved_by")
159
+ waiver_date = data.get("date")
160
+ if not isinstance(reason, str) or not reason.strip():
161
+ raise ValueError("sponsor waiver.reason must be a non-empty string")
162
+ if not isinstance(approved_by, str) or not approved_by.strip():
163
+ raise ValueError("sponsor waiver.approved_by must be a non-empty string")
164
+ if not isinstance(waiver_date, str) or not waiver_date.strip():
165
+ raise ValueError("sponsor waiver.date must be a non-empty string")
166
+ try:
167
+ date.fromisoformat(waiver_date)
168
+ except ValueError as exc:
169
+ raise ValueError("sponsor waiver.date must use ISO format YYYY-MM-DD") from exc
170
+ return SponsorWaiver(path=path, reason=reason.strip(), date=waiver_date.strip(), approved_by=approved_by.strip())
171
+
172
+
173
+ def load_waiver(path: str | Path | None) -> SponsorWaiver | None:
174
+ if path is None:
175
+ return None
176
+ path = Path(path)
177
+ if not path.exists():
178
+ return None
179
+ return _validate_waiver(path, _load_data(path))
180
+
181
+
182
+ def _registry_entry_for_requirement(registry: dict[str, Any], requirement: SponsorRequirement) -> dict[str, Any] | None:
183
+ if requirement.scope == "shared":
184
+ shared = registry.get("shared", {})
185
+ if not isinstance(shared, dict):
186
+ raise ValueError("model registry.shared must be a mapping")
187
+ entry = shared.get(requirement.key)
188
+ return entry if isinstance(entry, dict) else None
189
+ if requirement.scope == "projects":
190
+ projects = registry.get("projects", {})
191
+ if not isinstance(projects, dict):
192
+ raise ValueError("model registry.projects must be a mapping")
193
+ project = projects.get(requirement.key)
194
+ if not isinstance(project, dict):
195
+ return None
196
+ if "model_id" in project or "component" in project:
197
+ return project
198
+ entry = project.get(requirement.component)
199
+ return entry if isinstance(entry, dict) else None
200
+ raise ValueError(f"unsupported sponsor requirement scope: {requirement.scope!r}")
201
+
202
+
203
+ def check_sponsor_policy(
204
+ model_registry_path: str | Path,
205
+ policy_path: str | Path,
206
+ waiver_path: str | Path | None = None,
207
+ ) -> SponsorPolicyCheckResult:
208
+ registry = load_registry(model_registry_path)
209
+ policy = load_policy(policy_path)
210
+ requirements = _iter_requirements(policy)
211
+ waiver = load_waiver(waiver_path)
212
+
213
+ mismatches: list[SponsorMismatch] = []
214
+ for requirement in requirements:
215
+ entry = _registry_entry_for_requirement(registry, requirement)
216
+ if entry is None:
217
+ mismatches.append(
218
+ SponsorMismatch(
219
+ scope=requirement.scope,
220
+ key=requirement.key,
221
+ expected_component=requirement.component,
222
+ expected_model_id=requirement.model_id,
223
+ actual_component=None,
224
+ actual_model_id=None,
225
+ problem="missing registry entry",
226
+ )
227
+ )
228
+ continue
229
+ actual_component = entry.get("component")
230
+ actual_model_id = entry.get("model_id")
231
+ if actual_component != requirement.component or actual_model_id != requirement.model_id:
232
+ if actual_component != requirement.component and actual_model_id != requirement.model_id:
233
+ problem = "component and model_id mismatch"
234
+ elif actual_component != requirement.component:
235
+ problem = "component mismatch"
236
+ else:
237
+ problem = "model_id mismatch"
238
+ mismatches.append(
239
+ SponsorMismatch(
240
+ scope=requirement.scope,
241
+ key=requirement.key,
242
+ expected_component=requirement.component,
243
+ expected_model_id=requirement.model_id,
244
+ actual_component=actual_component if isinstance(actual_component, str) else None,
245
+ actual_model_id=actual_model_id if isinstance(actual_model_id, str) else None,
246
+ problem=problem,
247
+ )
248
+ )
249
+
250
+ ok = not mismatches or waiver is not None
251
+ return SponsorPolicyCheckResult(ok=ok, requirements=requirements, mismatches=tuple(mismatches), waiver=waiver)
252
+
253
+
254
+ def format_sponsor_policy_result(result: SponsorPolicyCheckResult) -> tuple[str, str, int]:
255
+ if result.mismatches and result.waiver is None:
256
+ lines = ["ERROR: sponsor model mismatch detected:"]
257
+ for mismatch in result.mismatches:
258
+ actual_component = mismatch.actual_component or "<missing>"
259
+ actual_model_id = mismatch.actual_model_id or "<missing>"
260
+ lines.append(
261
+ f"- {mismatch.scope}.{mismatch.key}: expected component={mismatch.expected_component!r}, "
262
+ f"model_id={mismatch.expected_model_id!r}; got component={actual_component!r}, "
263
+ f"model_id={actual_model_id!r} ({mismatch.problem})"
264
+ )
265
+ lines.append("Fix configs/model_registry.yaml or provide configs/sponsor_model_waiver.yaml to justify the exception.")
266
+ return ("", "\n".join(lines), 1)
267
+
268
+ if result.mismatches and result.waiver is not None:
269
+ warning_lines = [
270
+ "WARNING: sponsor model mismatch waived.",
271
+ f"- waiver file: {result.waiver.path}",
272
+ f"- approved_by: {result.waiver.approved_by}",
273
+ f"- date: {result.waiver.date}",
274
+ f"- reason: {result.waiver.reason}",
275
+ ]
276
+ stdout = "Sponsor model policy check passed with waiver."
277
+ return (stdout, "\n".join(warning_lines), 0)
278
+
279
+ stdout = f"Sponsor model policy check passed: {len(result.requirements)} required sponsor model(s) aligned."
280
+ return (stdout, "", 0)
app_kit/storage.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+ import shutil
10
+ import uuid
11
+
12
+ from .embedding import SimpleEmbeddingIndex
13
+
14
+
15
+ DEFAULT_DB_PATH = Path('app_data.sqlite3')
16
+
17
+
18
+ def init_db(db_path: str | Path = DEFAULT_DB_PATH, artifact_dir: str | Path | None = None) -> None:
19
+ db_path = Path(db_path)
20
+ artifact_dir = Path(artifact_dir) if artifact_dir is not None else db_path.parent / 'artifacts'
21
+ store = SQLiteStore(db_path, artifact_dir)
22
+ store.close()
23
+
24
+
25
+ def reset_db(db_path: str | Path = DEFAULT_DB_PATH, artifact_dir: str | Path | None = None) -> None:
26
+ db_path = Path(db_path)
27
+ if db_path.exists():
28
+ db_path.unlink()
29
+ init_db(db_path, artifact_dir)
30
+
31
+ SCHEMA = """
32
+ CREATE TABLE IF NOT EXISTS artifacts (
33
+ id TEXT PRIMARY KEY,
34
+ project TEXT NOT NULL,
35
+ pack_id TEXT NOT NULL,
36
+ type TEXT NOT NULL,
37
+ path TEXT NOT NULL,
38
+ created_at TEXT NOT NULL,
39
+ metadata_json TEXT NOT NULL
40
+ );
41
+ CREATE TABLE IF NOT EXISTS records (
42
+ id TEXT PRIMARY KEY,
43
+ project TEXT NOT NULL,
44
+ pack_id TEXT NOT NULL,
45
+ title TEXT NOT NULL,
46
+ primary_text TEXT NOT NULL,
47
+ json_blob TEXT NOT NULL,
48
+ status TEXT NOT NULL,
49
+ created_at TEXT NOT NULL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS embeddings (
52
+ record_id TEXT PRIMARY KEY,
53
+ project TEXT NOT NULL,
54
+ vector_json TEXT NOT NULL,
55
+ metadata_json TEXT NOT NULL,
56
+ created_at TEXT NOT NULL
57
+ );
58
+ """
59
+
60
+
61
+ @dataclass
62
+ class StoredPackResult:
63
+ record_id: str
64
+ title: str
65
+ primary_text: str
66
+ json_blob: dict[str, Any]
67
+ status: str
68
+
69
+
70
+ def utc_now() -> str:
71
+ return datetime.now(timezone.utc).isoformat()
72
+
73
+
74
+ class SQLiteStore:
75
+ def __init__(self, db_path: str | Path, artifact_dir: str | Path):
76
+ self.db_path = Path(db_path)
77
+ self.artifact_dir = Path(artifact_dir)
78
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
79
+ self.artifact_dir.mkdir(parents=True, exist_ok=True)
80
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
81
+ self._conn.row_factory = sqlite3.Row
82
+ self._conn.executescript(SCHEMA)
83
+ self._conn.commit()
84
+
85
+ def close(self) -> None:
86
+ self._conn.close()
87
+
88
+ def store_artifact(self, project: str, pack_id: str, source_path: Path, kind: str, metadata: dict[str, Any] | None = None) -> str:
89
+ artifact_id = str(uuid.uuid4())
90
+ dest = self.artifact_dir / f'{artifact_id}{source_path.suffix or ".bin"}'
91
+ shutil.copy2(source_path, dest)
92
+ self._conn.execute(
93
+ 'INSERT INTO artifacts VALUES (?, ?, ?, ?, ?, ?, ?)',
94
+ (artifact_id, project, pack_id, kind, str(dest), utc_now(), json.dumps(metadata or {}, ensure_ascii=False)),
95
+ )
96
+ self._conn.commit()
97
+ return artifact_id
98
+
99
+ def store_record(self, project: str, pack_id: str, title: str, primary_text: str, payload: dict[str, Any], status: str = 'stored', record_id: str | None = None) -> str:
100
+ record_id = record_id or str(uuid.uuid4())
101
+ self._conn.execute(
102
+ 'INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
103
+ (record_id, project, pack_id, title, primary_text, json.dumps(payload, ensure_ascii=False), status, utc_now()),
104
+ )
105
+ self._conn.commit()
106
+ return record_id
107
+
108
+ def store_embedding(self, record_id: str, project: str, text: str, metadata: dict[str, Any] | None = None) -> None:
109
+ vec = SimpleEmbeddingIndex()
110
+ vec.add(record_id, text)
111
+ vector_json = json.dumps({token: count for token, count in vec.entries[record_id].items()}, ensure_ascii=False)
112
+ self._conn.execute(
113
+ 'INSERT OR REPLACE INTO embeddings VALUES (?, ?, ?, ?, ?)',
114
+ (record_id, project, vector_json, json.dumps(metadata or {}, ensure_ascii=False), utc_now()),
115
+ )
116
+ self._conn.commit()
117
+
118
+ def _embedding_index(self, project: str) -> SimpleEmbeddingIndex:
119
+ index = SimpleEmbeddingIndex()
120
+ rows = self._conn.execute('SELECT record_id, vector_json FROM embeddings WHERE project = ?', (project,)).fetchall()
121
+ for row in rows:
122
+ index.entries[row['record_id']] = __import__('collections').Counter(json.loads(row['vector_json']))
123
+ return index
124
+
125
+ def search_records(self, project: str, query: str, limit: int = 5) -> list[dict[str, Any]]:
126
+ index = self._embedding_index(project)
127
+ scored = index.search(query, limit=limit)
128
+ if not scored:
129
+ return []
130
+ ids = [record_id for record_id, score in scored if score > 0]
131
+ if not ids:
132
+ ids = [record_id for record_id, _ in scored]
133
+ out = []
134
+ for record_id in ids:
135
+ row = self._conn.execute('SELECT * FROM records WHERE id = ?', (record_id,)).fetchone()
136
+ if row:
137
+ out.append(dict(row))
138
+ return out[:limit]
139
+
140
+ def list_records(self, project: str) -> list[dict[str, Any]]:
141
+ rows = self._conn.execute('SELECT * FROM records WHERE project = ? ORDER BY created_at DESC', (project,)).fetchall()
142
+ return [dict(row) for row in rows]
143
+
144
+ def get_record(self, record_id: str) -> dict[str, Any] | None:
145
+ row = self._conn.execute('SELECT * FROM records WHERE id = ?', (record_id,)).fetchone()
146
+ return dict(row) if row else None
147
+
148
+ def inbox(self, project: str) -> list[dict[str, Any]]:
149
+ return self.list_records(project)
150
+
151
+ def history(self, project: str) -> list[dict[str, Any]]:
152
+ return self.list_records(project)
app_kit/tracing.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from dataclasses import asdict, is_dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _CANONICAL_INPUT_KEYS = (
11
+ 'inputs',
12
+ 'input',
13
+ 'pack',
14
+ 'pack_id',
15
+ 'pack_name',
16
+ 'pack_path',
17
+ 'project',
18
+ 'scenario_id',
19
+ 'scenario_count',
20
+ 'query',
21
+ 'prompt',
22
+ 'transcript',
23
+ 'text',
24
+ )
25
+ _MODEL_HINT_KEYS = (
26
+ 'model_name',
27
+ 'base_model_id',
28
+ 'base_model',
29
+ 'model_id',
30
+ 'adapter_name',
31
+ 'loaded_from',
32
+ 'adapter_path',
33
+ )
34
+
35
+ def utc_now() -> str:
36
+ return datetime.now(timezone.utc).isoformat(timespec='seconds')
37
+
38
+ def _json_safe(value: Any) -> Any:
39
+ if is_dataclass(value):
40
+ return _json_safe(asdict(value))
41
+ if isinstance(value, Path):
42
+ return str(value)
43
+ if isinstance(value, dict):
44
+ return {str(key): _json_safe(item) for key, item in value.items()}
45
+ if isinstance(value, (list, tuple)):
46
+ return [_json_safe(item) for item in value]
47
+ return value
48
+
49
+ def _nonempty(value: Any) -> bool:
50
+ return value not in (None, '', [], {}, ())
51
+
52
+ def _infer_input_payload(payload: dict[str, Any]) -> Any:
53
+ existing_inputs = payload.get('inputs')
54
+ if _nonempty(existing_inputs):
55
+ return existing_inputs
56
+ inputs: dict[str, Any] = {}
57
+ for key in _CANONICAL_INPUT_KEYS:
58
+ if key in payload and key != 'inputs' and _nonempty(payload[key]):
59
+ inputs[key] = payload[key]
60
+ if inputs:
61
+ return inputs
62
+ for key in ('project', 'pack_id', 'pack_name', 'pack_path', 'pack'):
63
+ if key in payload and _nonempty(payload[key]):
64
+ inputs[key] = payload[key]
65
+ return inputs
66
+
67
+ def _infer_model_name(value: Any) -> str | None:
68
+ if isinstance(value, dict):
69
+ for key in _MODEL_HINT_KEYS:
70
+ candidate = value.get(key)
71
+ if isinstance(candidate, str) and candidate.strip():
72
+ return candidate.strip()
73
+ for item in value.values():
74
+ candidate = _infer_model_name(item)
75
+ if candidate:
76
+ return candidate
77
+ elif isinstance(value, list):
78
+ for item in value:
79
+ candidate = _infer_model_name(item)
80
+ if candidate:
81
+ return candidate
82
+ return None
83
+
84
+ def canonicalize_trace_payload(payload: dict[str, Any]) -> dict[str, Any]:
85
+ normalized = _json_safe(payload)
86
+ timestamp = normalized.get('timestamp') or normalized.get('finished_at') or normalized.get('started_at') or utc_now()
87
+ parsed_outputs = normalized.get('parsed_outputs')
88
+ if not _nonempty(parsed_outputs):
89
+ parsed_outputs = normalized.get('result')
90
+ if not _nonempty(parsed_outputs):
91
+ parsed_outputs = normalized.get('results')
92
+ if not _nonempty(parsed_outputs):
93
+ parsed_outputs = normalized.get('output')
94
+ if not _nonempty(parsed_outputs):
95
+ parsed_outputs = {}
96
+ model_name = normalized.get('model_name')
97
+ if not _nonempty(model_name):
98
+ model_name = _infer_model_name(parsed_outputs) or _infer_model_name(normalized)
99
+ if not _nonempty(model_name):
100
+ model_name = f"{normalized.get('project') or normalized.get('kind') or 'unknown'}:rule-based"
101
+ normalized['timestamp'] = str(timestamp)
102
+ normalized['inputs'] = _infer_input_payload(normalized)
103
+ normalized['parsed_outputs'] = parsed_outputs
104
+ normalized['model_name'] = str(model_name)
105
+ return normalized
106
+
107
+ def write_trace_artifact(artifact_dir: str | Path, payload: dict[str, Any]) -> Path:
108
+ artifact_dir = Path(artifact_dir)
109
+ trace_dir = artifact_dir / 'traces'
110
+ trace_dir.mkdir(parents=True, exist_ok=True)
111
+ run_id = str(payload.get('run_id') or uuid.uuid4().hex)
112
+ kind = str(payload.get('kind', 'trace')).strip().replace('/', '_').replace(' ', '_') or 'trace'
113
+ trace_path = trace_dir / f'{kind}-{run_id}.json'
114
+ normalized = canonicalize_trace_payload(payload)
115
+ normalized['run_id'] = run_id
116
+ normalized['trace_path'] = str(trace_path)
117
+ trace_path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False, sort_keys=True, default=str), encoding='utf-8')
118
+ return trace_path
assets/theme.css ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* P1 Elder Care Document Assistant — calming, accessible dark UI */
2
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap');
3
+
4
+ :root {
5
+ color-scheme: dark;
6
+ --p1-bg-deep: #0c1220;
7
+ --p1-bg-card: rgba(15, 25, 50, 0.65);
8
+ --p1-bg-surface: rgba(30, 45, 80, 0.35);
9
+ --p1-text: #f8fafc;
10
+ --p1-text-muted: #94a3c8;
11
+ --p1-accent: #fbbf24;
12
+ --p1-accent-hover: #f59e0b;
13
+ --p1-accent-glow: rgba(251, 191, 36, 0.15);
14
+ --p1-success: #34d399;
15
+ --p1-warning: #fbbf24;
16
+ --p1-danger: #f87171;
17
+ --p1-info: #60a5fa;
18
+ --p1-border: rgba(96, 165, 250, 0.18);
19
+ --p1-radius: 16px;
20
+ --p1-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
21
+ }
22
+
23
+ /* Base container */
24
+ .gradio-container {
25
+ background:
26
+ radial-gradient(ellipse at top left, rgba(59, 130, 246, 0.08) 0%, transparent 50%),
27
+ radial-gradient(ellipse at bottom right, rgba(251, 191, 36, 0.05) 0%, transparent 50%),
28
+ linear-gradient(180deg, #0f172a 0%, #0c1220 40%, #020617 100%);
29
+ color: var(--p1-text);
30
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
31
+ font-size: 16px;
32
+ line-height: 1.6;
33
+ min-height: 100vh;
34
+ }
35
+
36
+ /* Typography */
37
+ .gradio-container .prose,
38
+ .gradio-container p,
39
+ .gradio-container span,
40
+ .gradio-container label {
41
+ color: var(--p1-text);
42
+ }
43
+
44
+ .gradio-container h1 {
45
+ font-family: 'Outfit', system-ui, sans-serif;
46
+ color: var(--p1-accent);
47
+ font-size: 2rem;
48
+ font-weight: 700;
49
+ letter-spacing: -0.01em;
50
+ text-shadow: 0 0 40px var(--p1-accent-glow);
51
+ }
52
+
53
+ .gradio-container h2,
54
+ .gradio-container h3 {
55
+ font-family: 'Outfit', system-ui, sans-serif;
56
+ color: var(--p1-text);
57
+ font-weight: 600;
58
+ letter-spacing: -0.005em;
59
+ }
60
+
61
+ .gradio-container h4 {
62
+ color: var(--p1-text-muted);
63
+ }
64
+
65
+ /* Input fields */
66
+ .gradio-container input,
67
+ .gradio-container textarea,
68
+ .gradio-container select {
69
+ background: var(--p1-bg-card);
70
+ color: var(--p1-text);
71
+ border: 1px solid var(--p1-border);
72
+ border-radius: var(--p1-radius);
73
+ backdrop-filter: blur(8px);
74
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
75
+ }
76
+
77
+ .gradio-container input:focus,
78
+ .gradio-container textarea:focus {
79
+ border-color: var(--p1-accent);
80
+ box-shadow: 0 0 0 3px var(--p1-accent-glow);
81
+ outline: none;
82
+ }
83
+
84
+ /* Buttons */
85
+ .gradio-container button {
86
+ border-radius: 999px;
87
+ font-weight: 600;
88
+ font-family: 'Inter', system-ui, sans-serif;
89
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
90
+ letter-spacing: 0.01em;
91
+ }
92
+
93
+ .gradio-container button.primary,
94
+ .gradio-container button.lg.primary {
95
+ background: linear-gradient(135deg, var(--p1-accent) 0%, #f97316 100%);
96
+ color: #111827;
97
+ font-weight: 700;
98
+ box-shadow: 0 4px 16px rgba(251, 191, 36, 0.25);
99
+ border: none;
100
+ }
101
+
102
+ .gradio-container button.primary:hover {
103
+ transform: translateY(-1px);
104
+ box-shadow: 0 6px 24px rgba(251, 191, 36, 0.35);
105
+ }
106
+
107
+ .gradio-container button.secondary {
108
+ background: var(--p1-bg-card);
109
+ color: var(--p1-text);
110
+ border: 1px solid var(--p1-border);
111
+ backdrop-filter: blur(8px);
112
+ }
113
+
114
+ .gradio-container button.secondary:hover {
115
+ border-color: var(--p1-accent);
116
+ background: var(--p1-bg-surface);
117
+ }
118
+
119
+ /* Upload area */
120
+ .gradio-container .upload-area {
121
+ border: 2px dashed rgba(96, 165, 250, 0.3);
122
+ border-radius: 20px;
123
+ background: var(--p1-bg-card);
124
+ backdrop-filter: blur(12px);
125
+ transition: border-color 0.3s ease, background 0.3s ease;
126
+ }
127
+
128
+ .gradio-container .upload-area:hover {
129
+ border-color: var(--p1-accent);
130
+ background: rgba(251, 191, 36, 0.05);
131
+ }
132
+
133
+ /* Result cards */
134
+ .gradio-container .result-card,
135
+ .gradio-container .history-card,
136
+ .gradio-container .status-box {
137
+ background: var(--p1-bg-card);
138
+ border: 1px solid var(--p1-border);
139
+ border-radius: var(--p1-radius);
140
+ padding: 1.25rem;
141
+ backdrop-filter: blur(12px);
142
+ box-shadow: var(--p1-shadow);
143
+ color: var(--p1-text) !important;
144
+ }
145
+
146
+ .gradio-container .result-card p,
147
+ .gradio-container .history-card p,
148
+ .gradio-container .status-box p,
149
+ .gradio-container .result-card span,
150
+ .gradio-container .history-card span {
151
+ color: var(--p1-text) !important;
152
+ }
153
+
154
+
155
+ .gradio-container .result-card h3 {
156
+ margin-top: 0;
157
+ }
158
+
159
+ /* Search box */
160
+ .gradio-container .search-box input {
161
+ padding-left: 1rem;
162
+ }
163
+
164
+ /* Accordion */
165
+ .gradio-container .accordion {
166
+ background: var(--p1-bg-surface);
167
+ border: 1px solid var(--p1-border);
168
+ border-radius: var(--p1-radius);
169
+ }
170
+
171
+ /* Markdown formatting within results */
172
+ .gradio-container .markdown-text blockquote {
173
+ border-left: 3px solid var(--p1-accent);
174
+ padding-left: 1rem;
175
+ margin-left: 0;
176
+ color: var(--p1-text-muted);
177
+ font-style: italic;
178
+ }
179
+
180
+ .gradio-container .markdown-text code {
181
+ background: rgba(96, 165, 250, 0.12);
182
+ color: var(--p1-info);
183
+ padding: 0.15rem 0.4rem;
184
+ border-radius: 6px;
185
+ font-size: 0.85em;
186
+ }
187
+
188
+ .gradio-container .markdown-text hr {
189
+ border-color: var(--p1-border);
190
+ margin: 1rem 0;
191
+ }
192
+
193
+ /* JSON panel (kept for dev accordion) */
194
+ .gradio-container .json-holder {
195
+ background: var(--p1-bg-card);
196
+ border: 1px solid var(--p1-border);
197
+ border-radius: var(--p1-radius);
198
+ }
199
+
200
+ /* Scrollbar */
201
+ .gradio-container ::-webkit-scrollbar {
202
+ width: 6px;
203
+ }
204
+
205
+ .gradio-container ::-webkit-scrollbar-track {
206
+ background: transparent;
207
+ }
208
+
209
+ .gradio-container ::-webkit-scrollbar-thumb {
210
+ background: rgba(96, 165, 250, 0.25);
211
+ border-radius: 4px;
212
+ }
213
+
214
+ /* Footer */
215
+ .gradio-container footer {
216
+ opacity: 0.5;
217
+ }
218
+
219
+ /* Responsive */
220
+ @media (max-width: 768px) {
221
+ .gradio-container h1 {
222
+ font-size: 1.5rem;
223
+ }
224
+ }
225
+
226
+ /* Micro-animations */
227
+ @keyframes fadeIn {
228
+ from { opacity: 0; transform: translateY(8px); }
229
+ to { opacity: 1; transform: translateY(0); }
230
+ }
231
+
232
+ .gradio-container .result-card,
233
+ .gradio-container .history-card {
234
+ animation: fadeIn 0.4s ease-out;
235
+ }
configs/model_registry.yaml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ shared:
2
+ embedder:
3
+ component: embedder
4
+ model_id: sentence-transformers/all-MiniLM-L6-v2
5
+ license: Apache-2.0
6
+ usage_notes: Offline embedding baseline for search and retrieval helpers.
7
+ runtime: heuristic+hf
8
+ backend: sentence-transformers
9
+ local_fallback: builtin-text-similarity
10
+ summary_llm:
11
+ component: summary_llm
12
+ model_id: openbmb/MiniCPM-5-1B
13
+ license: MiniCPM-custom
14
+ usage_notes: Plain-language summaries and triage narration for elder paperwork.
15
+ runtime: heuristic+hf
16
+ backend: transformers
17
+ local_fallback: deterministic-template
18
+ projects:
19
+ p1:
20
+ ocr_vlm:
21
+ component: ocr_vlm
22
+ model_id: openbmb/MiniCPM-V-4_6
23
+ params: "~1.3B"
24
+ sponsor: MiniCPM
25
+ license: MiniCPM-custom
26
+ usage_notes: OCR and layout understanding for scanned elder paperwork.
27
+ runtime: heuristic+hf
28
+ backend: transformers
29
+ local_fallback: deterministic-ocr
30
+ triage_llm:
31
+ component: triage_llm
32
+ model_id: openbmb/MiniCPM-5-1B
33
+ params: "1B"
34
+ sponsor: MiniCPM
35
+ license: MiniCPM-custom
36
+ usage_notes: Triage labels and plain-language summaries for elder paperwork packets.
37
+ runtime: heuristic+hf
38
+ backend: transformers
39
+ local_fallback: deterministic-template
40
+ table_parser:
41
+ component: table_parser
42
+ model_id: nvidia/NVIDIA-Nemotron-Parse-v1.1
43
+ params: "<1B"
44
+ sponsor: NeMoTRON
45
+ license: NVIDIA
46
+ usage_notes: Structured extraction of tables and form fields from bills and notices.
47
+ runtime: heuristic+hf
48
+ backend: transformers
49
+ local_fallback: deterministic-table-parser
50
+ asr:
51
+ component: asr
52
+ model_id: CohereLabs/cohere-transcribe-03-2026
53
+ params: "2B"
54
+ sponsor: CoExpression
55
+ license: proprietary
56
+ usage_notes: Voice-note transcription for caregiver follow-ups.
57
+ runtime: heuristic+hf
58
+ backend: transformers
59
+ local_fallback: offline-transcribe
configs/sponsor_model_policy.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 1
2
+ purpose: sponsor-model alignment gate for the ALL4 P1 elder paperwork repo
3
+ required_models:
4
+ projects:
5
+ p1:
6
+ ocr_vlm:
7
+ component: ocr_vlm
8
+ model_id: openbmb/MiniCPM-V-4_6
9
+ triage_llm:
10
+ component: triage_llm
11
+ model_id: openbmb/MiniCPM-5-1B
12
+ table_parser:
13
+ component: table_parser
14
+ model_id: nvidia/NVIDIA-Nemotron-Parse-v1.1
15
+ asr:
16
+ component: asr
17
+ model_id: CohereLabs/cohere-transcribe-03-2026
data/README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Data
2
+
3
+ Bundled demo packs:
4
+ - `p1_elder_paperwork/` — synthetic elder-paperwork notices, OCR-lite samples, and test packs for the P1 app.
5
+
6
+ All bundled P1 demo assets are synthetic or generated for this repository and are intended to be redistributable in a public Hugging Face Space.
7
+
8
+ License for bundled demo assets: CC0-1.0.
9
+ No PII/PHI is included.
data/demo_packs/p1_elder_paperwork/sample_appointment_notice/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_appointment_notice
data/demo_packs/p1_elder_paperwork/sample_appointment_notice/inputs/note.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Appointment notice for a routine follow-up visit next Tuesday. Please bring your medication list and insurance card.
data/demo_packs/p1_elder_paperwork/sample_appointment_notice/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_appointment_notice",
4
+ "description": "p1_elder_paperwork demo pack sample_appointment_notice",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/note.txt",
8
+ "kind": "txt",
9
+ "label": "sample_appointment_notice"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "important"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Appointment notice for a routine follow-up visit next Tuesday. Please bring your medication list and insurance card."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_benefits_renewal_png
data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_benefits_renewal_png",
4
+ "description": "p1_elder_paperwork demo pack sample_benefits_renewal_png",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/benefits.png",
8
+ "kind": "png",
9
+ "label": "sample_benefits_renewal_png"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "important"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Benefits renewal reminder. Return the verification form within 10 days to keep coverage active."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_clinic_reminder_txt
data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/inputs/reminder.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Clinic reminder: annual wellness visit scheduled for next month. This is a routine informational notice.
data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_clinic_reminder_txt",
4
+ "description": "p1_elder_paperwork demo pack sample_clinic_reminder_txt",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/reminder.txt",
8
+ "kind": "txt",
9
+ "label": "sample_clinic_reminder_txt"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "important"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Clinic reminder: annual wellness visit scheduled for next month. This is a routine informational notice."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_final_notice_pdf
data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/inputs/notice.pdf ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %PDF-1.4
2
+ 1 0 obj
3
+ << /Type /Catalog /Pages 2 0 R >>
4
+ endobj
5
+ 2 0 obj
6
+ << /Type /Pages /Kids [3 0 R] /Count 1 >>
7
+ endobj
8
+ 3 0 obj
9
+ << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
10
+ endobj
11
+ 4 0 obj
12
+ << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
13
+ endobj
14
+ 5 0 obj
15
+ << /Length 135 >>
16
+ stream
17
+ BT /F1 12 Tf 72 720 Td (Final notice: your account is past due. Please pay the remaining balance by 06/18/2026 or call 555-0142.) Tj ET
18
+ endstream
19
+ endobj
20
+ xref
21
+ 0 6
22
+ 0000000000 65535 f
23
+ 0000000009 00000 n
24
+ 0000000058 00000 n
25
+ 0000000115 00000 n
26
+ 0000000241 00000 n
27
+ 0000000311 00000 n
28
+ trailer
29
+ << /Size 6 /Root 1 0 R >>
30
+ startxref
31
+ 497
32
+ %%EOF
data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_final_notice_pdf",
4
+ "description": "p1_elder_paperwork demo pack sample_final_notice_pdf",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/notice.pdf",
8
+ "kind": "pdf",
9
+ "label": "sample_final_notice_pdf"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "urgent"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Final notice: your account is past due. Please pay the remaining balance by 06/18/2026 or call 555-0142."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_insurance_letter_png
data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_insurance_letter_png",
4
+ "description": "p1_elder_paperwork demo pack sample_insurance_letter_png",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/insurance_letter.png",
8
+ "kind": "png",
9
+ "label": "sample_insurance_letter_png"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "important"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Insurance letter with coverage update and appeal rights. Contact member services if the mailing address is wrong."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_lab_reminder_txt
data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/inputs/lab_reminder.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Lab reminder: routine blood work is due sometime this month. No action is needed today.
data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_lab_reminder_txt",
4
+ "description": "p1_elder_paperwork demo pack sample_lab_reminder_txt",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/lab_reminder.txt",
8
+ "kind": "txt",
9
+ "label": "sample_lab_reminder_txt"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "important"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Lab reminder: routine blood work is due sometime this month. No action is needed today."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_medication_change_pdf
data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/inputs/medication_change.pdf ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %PDF-1.4
2
+ 1 0 obj
3
+ << /Type /Catalog /Pages 2 0 R >>
4
+ endobj
5
+ 2 0 obj
6
+ << /Type /Pages /Kids [3 0 R] /Count 1 >>
7
+ endobj
8
+ 3 0 obj
9
+ << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
10
+ endobj
11
+ 4 0 obj
12
+ << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
13
+ endobj
14
+ 5 0 obj
15
+ << /Length 152 >>
16
+ stream
17
+ BT /F1 12 Tf 72 720 Td (Medication change update from clinic. Continue current dose and review the attached instructions at the next appointment.) Tj ET
18
+ endstream
19
+ endobj
20
+ xref
21
+ 0 6
22
+ 0000000000 65535 f
23
+ 0000000009 00000 n
24
+ 0000000058 00000 n
25
+ 0000000115 00000 n
26
+ 0000000241 00000 n
27
+ 0000000311 00000 n
28
+ trailer
29
+ << /Size 6 /Root 1 0 R >>
30
+ startxref
31
+ 514
32
+ %%EOF
data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_medication_change_pdf",
4
+ "description": "p1_elder_paperwork demo pack sample_medication_change_pdf",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/medication_change.pdf",
8
+ "kind": "pdf",
9
+ "label": "sample_medication_change_pdf"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "important"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "Medication change update from clinic. Continue current dose and review the attached instructions at the next appointment."
18
+ }
data/demo_packs/p1_elder_paperwork/sample_urgent_notice/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ p1_elder_paperwork pack: sample_urgent_notice
data/demo_packs/p1_elder_paperwork/sample_urgent_notice/inputs/note.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ URGENT billing notice about a past due balance and follow-up deadline. Call the office by Friday.
data/demo_packs/p1_elder_paperwork/sample_urgent_notice/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "p1",
3
+ "pack_id": "p1_elder_paperwork.sample_urgent_notice",
4
+ "description": "p1_elder_paperwork demo pack sample_urgent_notice",
5
+ "inputs": [
6
+ {
7
+ "path": "inputs/note.txt",
8
+ "kind": "txt",
9
+ "label": "sample_urgent_notice"
10
+ }
11
+ ],
12
+ "expected_signals": {
13
+ "triage": "urgent"
14
+ },
15
+ "license": "CC0-1.0",
16
+ "source": "synthetic",
17
+ "primary_text": "URGENT billing notice about a past due balance and follow-up deadline. Call the office by Friday."
18
+ }