Dirbol Claude Haiku 4.5 commited on
Commit
1d5d841
·
1 Parent(s): 0a027e6

chore: pre-release — full project lifecycle (CRUD, history, docs, tests)

Browse files

Backend:
- GET /projects (list), DELETE /projects/{id}
- GET /inference/history
- project_id threaded through POST /work/infer + /work/blueprint (form-field)
- DailyWorkInput length caps (raw_text <= 4000, axes <= 200)
- Blueprint upload size cap (50 MB → 413)
- Supabase service: list_projects + delete_project + list_inference_history
- X-Request-ID middleware (echo / auto-generate + access log line)
- CORS constrained to the live Vercel deploy (no longer "*")
- inference_history payload now includes "persisted" hint to client

Frontend:
- /project list page → cards with cross-links to work/blueprint/documents/history
- /project/new rewired to POST /projects, surfaces returned project_id
- /work + /blueprint accept ?project_id= and thread it into the request
- /documents: full pick-project / pick-template / boilerplate / auto-download
- /history: read-only inference feed, filterable by project_id
- lib/api.ts: complete client for projects/crud/history/documents/templates

Tooling:
- pytest suite: schemas + Supabase mapping (7 tests, all pass)
- requirements-dev.txt: pytest + pytest-asyncio
- RELEASING.md: final-test checklist (mostly manual ~30 min)
- README.md updated to release-grade summary

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

README.md CHANGED
@@ -1,18 +1,87 @@
1
- ---
2
- title: Virtual Foreman Backend
3
- emoji: 🏗️
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: docker
7
- app_port: 7860
8
- pinned: true
9
- license: mit
10
- ---
11
-
12
- # Virtual Foreman Backend API
13
-
14
- FastAPI service providing:
15
- - `/work/infer` — AI tech-stack synthesis (OpenRouter free models)
16
- - `/documents/render` — Belarusian act generation (docxtpl)
17
- - `/projects` — metadata (Supabase-backed)
18
- - Swagger UI: `/docs`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Виртуальный Прораб — Virtual Foreman
2
+
3
+ > Construction tech-stack inference and document engine for
4
+ > Belarusian building sites. AI-assisted daily reports, blueprint
5
+ > reading, and TKP-compliant act generation.
6
+
7
+ | | |
8
+ |---|---|
9
+ | Production backend | https://dirbol-virtforemanbackend.hf.space |
10
+ | Production frontend | https://virt-foreman.vercel.app |
11
+ | OpenAPI docs | `/docs` (on the backend URL) |
12
+ | License | MIT |
13
+ | Stack | FastAPI · Next.js · Supabase · NVIDIA integrate · docxtpl |
14
+
15
+ ## Features
16
+
17
+ - **Daily wizard** (`/work`)write a one-line statement on the
18
+ smartphone, get a full engineering tech-stack back from the chief
19
+ engineer persona (DeepSeek V4 Flash → fallback chain).
20
+ - **Blueprint upload** (`/blueprint`) — drop a PNG / JPEG / WebP / HEIC
21
+ / PDF. PDFs auto-rasterised server-side via `pypdfium2`, vision
22
+ handled by Llama-3.2-Vision (or Phi-4-multimodal fallback).
23
+ - **Document generation** (`/documents`) — pick a project + template,
24
+ the Belarusian act drops as `.docx` via `docxtpl`.
25
+ - **Projects CRUD** (`/project`, `/project/new`) — full Supabase
26
+ persistence (`/projects/{id}` GET / DELETE).
27
+ - **Inference history** (`/history`) — read-only feed of the last
28
+ inferences (text + blueprint) per project or globally.
29
+ - **Mobile-first** — every page is sub-md max-width and tap-friendly.
30
+
31
+ ## Architecture
32
+
33
+ ```
34
+ ┌──────────────────────────────────────────────────────────────────┐
35
+ │ Vercel (Next.js 14 app router) │
36
+ │ / /project /work /blueprint /documents /history │
37
+ └─────────┬────────────────────────────────────────────────────────┘
38
+ │ HTTPS NEXT_PUBLIC_API_URL
39
+
40
+ ┌──────────────────────────────────────────────────────────────────┐
41
+ │ HF Space (FastAPI on python:3.11-slim) │
42
+ │ POST /projects, /work/infer, /work/blueprint, /documents/render│
43
+ │ GET /projects, /projects/{id}, /inference/history │
44
+ │ intelligence: NVIDIA integrate (DeepSeek v4-flash) │
45
+ │ Vision: Llama-3.2-Vision-11b → 90b → Phi-4-multimodal │
46
+ └─────────┬────────────────────────────────────────────────────────┘
47
+ │ service-role key
48
+
49
+ ┌─────────────┐
50
+ │ Supabase │ projects (mapped from canonical names)
51
+ └─────────────┘ inference_history (custom audit table)
52
+ ```
53
+
54
+ ## Local development
55
+
56
+ ### Backend
57
+
58
+ ```bash
59
+ cd backend
60
+ pip install -r requirements.txt
61
+ cp .env.example .env # fill in real keys
62
+ uvicorn app.main:app --reload --port 7860
63
+ ```
64
+
65
+ ### Frontend
66
+
67
+ ```bash
68
+ npm install
69
+ NEXT_PUBLIC_API_URL=http://localhost:7860 npm run dev
70
+ ```
71
+
72
+ ### Tests
73
+
74
+ ```bash
75
+ pip install -r requirements-dev.txt
76
+ pytest tests/ -v
77
+ ```
78
+
79
+ ## Pre-release status
80
+
81
+ All implementation work is complete. See **RELEASING.md** for the final
82
+ checklist (≤ 30 minutes) — apply Supabase DDL, set HF secrets, run
83
+ the smoke pass, ship.
84
+
85
+ ## License
86
+
87
+ MIT.
RELEASING.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Release Checklist — Virtual Foreman
2
+
3
+ > Pre-production state right now. The following items remain — all can
4
+ > be completed in less than an hour.
5
+
6
+ ## ✅ Done
7
+
8
+ - Backend (FastAPI) is live on Hugging Face Space.
9
+ - Frontend (Next.js) is live on Vercel.
10
+ - All major routes wired end-to-end:
11
+ - `GET /health`, `GET /`, `GET /supabase/health`
12
+ - `POST /projects`, `GET /projects`, `GET /projects/{id}`,
13
+ `DELETE /projects/{id}`
14
+ - `POST /work/infer`, `POST /work/blueprint`
15
+ - `GET /documents/templates`, `POST /documents/render`
16
+ - `GET /inference/history`
17
+ - CORS constrained to the live Vercel deploy (`virt-foreman.vercel.app`)
18
+ plus subdomains and localhost.
19
+ - X-Request-ID middleware on every response.
20
+ - HF Space secret recipes documented in `backend/README.md`.
21
+ - Legacy OpenRouter fields retained but ignored; primary NVIDIA
22
+ integrate chain (`DeepSeek V4 Flash` + fallbacks) is active.
23
+ - Vision path uses `Llama-3.2-Vision-11B → 90B → Phi-4-multimodal`.
24
+ - PDF render pipeline (`pypdfium2 + Pillow`) renders first page to PNG
25
+ before VLM call, capped at 1536 px on longest edge.
26
+ - Stub fallbacks for inference exhaustion — UI never dead-ends.
27
+ - Pytest suite covers schemas + Supabase column mapping (7 tests).
28
+ - All commits authored `dirboll@gmail.com` (Vercel bot-check OK).
29
+
30
+ ## 🟡 Manual steps remaining (~ 30 minutes)
31
+
32
+ 1. **Apply the Supabase delta schema**
33
+ - Open https://supabase.com/dashboard/project/hjkubelrluueugdjsqyn/sql/new
34
+ - Paste contents of `backend/supabase/schema.sql`
35
+ - Click **Run** → confirm `inference_history` appears in the table
36
+ editor.
37
+
38
+ 2. **Verify secrets on the HF Space**
39
+ - https://huggingface.co/spaces/Dirbol/VirtForemanBackend/settings/secrets
40
+ - `SUPABASE_URL` ✅
41
+ - `SUPABASE_SECRET_KEY` (or `SUPABASE_KEY`) ✅
42
+ - `LLM_API_KEY` (= NVIDIA integrate API key) ✅
43
+ - Optional: `HF_TOKEN` for monitoring
44
+
45
+ 3. **Vercel one-time connect** (skipped in Termux)
46
+ - Vercel dashboard → "Add New Project" → "Import Git Repository"
47
+ - Pick `dirboll-commits/VirtForeman`
48
+ - Root directory: repo root (already Next.js-shaped)
49
+ - Env vars in Vercel project settings:
50
+ - `NEXT_PUBLIC_API_URL=https://dirbol-virtforemanbackend.hf.space`
51
+ - `NEXT_PUBLIC_APP_NAME=Virtual Foreman`
52
+
53
+ 4. **Health checks**
54
+ - `curl https://dirbol-virtforemanbackend.hf.space/health` → 200
55
+ - `curl https://dirbol-virtforemanbackend.hf.space/supabase/health`
56
+ → `{configured:true, reachable:true, rows_in_projects_sample:1}`
57
+ - `curl https://virt-foreman.vercel.app/` → 200, home page renders
58
+
59
+ 5. **End-to-end smoke** (manual, in browser)
60
+ - Create project → /work, /blueprint, /documents chained actions.
61
+ - Verify each inference shows up under /history.
62
+ - Verify document download opens in Word / LibreOffice.
63
+
64
+ 6. **Run the regression suite**
65
+ ```bash
66
+ pip install -r requirements-dev.txt
67
+ pytest tests/test_schemas.py tests/test_supabase_mapping.py -v
68
+ ```
69
+ Expected output: `7 passed`.
70
+
71
+ 7. **Production hardening (optional)**
72
+ - Rate-limit per IP on `/work/infer` and `/work/blueprint`.
73
+ - Auth layer (Supabase Auth widgets or OIDC for the frontend).
74
+ - Email verification before document download.
75
+
76
+ ## 📋 Deployment commands cheat-sheet
77
+
78
+ ```bash
79
+ # Backend → HF Space
80
+ git push hf <branch>:main
81
+
82
+ # Frontend → Vercel (Vercel auto-deploys on push to origin/main)
83
+ git push origin <branch>:refs/heads/main --force
84
+ ```
85
+
86
+ Both must succeed for a clean release. Roll back via Vercel "Promote previous deployment" / HF revert.
87
+
88
+ ## 🔁 Logs
89
+
90
+ - HF Space runtime: https://huggingface.co/spaces/Dirbol/VirtForemanBackend
91
+ → "Logs" tab → "Container" stream.
92
+ - Vercel: project → "Logs" tab.
93
+ - Local sanity: `pytest tests/ -v`.
app/blueprint/page.tsx CHANGED
@@ -1,5 +1,6 @@
1
  "use client";
2
 
 
3
  import { useState } from "react";
4
  import {
5
  BlueprintExtraction,
@@ -123,6 +124,19 @@ function SheetCard({ sheet }: { sheet: BlueprintSheet }) {
123
  }
124
 
125
  export default function BlueprintPage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  const [file, setFile] = useState<File | null>(null);
127
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
128
  const [loading, setLoading] = useState(false);
@@ -152,7 +166,7 @@ export default function BlueprintPage() {
152
  setResult(null);
153
  setLoading(true);
154
  try {
155
- const resp = await inferBlueprint(file);
156
  setResult(resp.extraction);
157
  } catch (err) {
158
  setError(err instanceof Error ? err.message : "Неизвестная ошибка");
@@ -167,6 +181,12 @@ export default function BlueprintPage() {
167
  <p className="mb-6 text-sm text-slate-600">
168
  Отправьте скан или PDF — ИИ разберёт листы, оси, размеры и заметки.
169
  </p>
 
 
 
 
 
 
170
 
171
  <form onSubmit={submit} className="space-y-4">
172
  <label className="block">
 
1
  "use client";
2
 
3
+ import { Suspense } from "react";
4
  import { useState } from "react";
5
  import {
6
  BlueprintExtraction,
 
124
  }
125
 
126
  export default function BlueprintPage() {
127
+ return (
128
+ <Suspense fallback={<main className="mx-auto max-w-md px-4 py-8" />}>
129
+ <BlueprintInner />
130
+ </Suspense>
131
+ );
132
+ }
133
+
134
+ function BlueprintInner() {
135
+ // Late-bind so this dynamic-bounds detection actually fires on Vercel.
136
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
137
+ const { useSearchParams } = require("next/navigation") as typeof import("next/navigation");
138
+ const params = useSearchParams();
139
+ const project_id = params?.get("project_id") || "";
140
  const [file, setFile] = useState<File | null>(null);
141
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
142
  const [loading, setLoading] = useState(false);
 
166
  setResult(null);
167
  setLoading(true);
168
  try {
169
+ const resp = await inferBlueprint(file, project_id || undefined);
170
  setResult(resp.extraction);
171
  } catch (err) {
172
  setError(err instanceof Error ? err.message : "Неизвестная ошибка");
 
181
  <p className="mb-6 text-sm text-slate-600">
182
  Отправьте скан или PDF — ИИ разберёт листы, оси, размеры и заметки.
183
  </p>
184
+ {project_id && (
185
+ <div className="mb-4 rounded-xl border border-foreman/30 bg-foreman/5 px-3 py-2 text-xs text-foreman">
186
+ Привязано к проекту:{" "}
187
+ <code className="font-mono">{project_id.slice(0, 8)}…</code>
188
+ </div>
189
+ )}
190
 
191
  <form onSubmit={submit} className="space-y-4">
192
  <label className="block">
app/documents/page.tsx CHANGED
@@ -1,43 +1,215 @@
1
  "use client";
2
 
3
- import Link from "next/link";
 
 
 
 
 
 
 
4
 
5
- const TEMPLATES = [
6
- { key: "act_hidden_works", title: "АОСР — скрытые работы" },
7
- { key: "act_osmotr_responsibility", title: "Акт осмотра ответственных конструкций" },
8
- { key: "general_work_log", title: "Общий журнал работ" },
9
- { key: "material_certificate", title: "Сертификат / паспорт на материал" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ];
11
 
12
  export default function DocumentsPage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  return (
14
  <main className="mx-auto max-w-md px-4 py-8">
15
- <h1 className="mb-6 text-2xl font-bold text-foreman">Документы</h1>
16
-
17
- <div className="rounded-2xl border border-slate-200 bg-white p-4">
18
- <p className="text-sm text-slate-700">
19
- Шаблоны загружаются с бэкенда через <code className="rounded bg-slate-100 px-1 py-0.5 text-xs">POST /documents/render</code>.
20
- </p>
21
- </div>
22
-
23
- <ul className="mt-6 space-y-2">
24
- {TEMPLATES.map((t) => (
25
- <li
26
- key={t.key}
27
- className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3"
 
 
 
 
 
28
  >
29
- <span className="text-sm font-medium text-slate-800">{t.title}</span>
30
- <span className="text-xs text-slate-400">{t.key}.docx</span>
31
- </li>
32
- ))}
33
- </ul>
34
-
35
- <Link
36
- href="/"
37
- className="mt-8 block text-center text-sm text-foreman underline-offset-2 hover:underline"
38
- >
39
- На главную
40
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </main>
42
  );
43
  }
 
1
  "use client";
2
 
3
+ import { Suspense } from "react";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import {
6
+ Project,
7
+ listDocumentTemplates,
8
+ listProjects,
9
+ renderDocument,
10
+ } from "@/lib/api";
11
 
12
+ interface TemplateMeta {
13
+ key: string;
14
+ title: string;
15
+ description: string;
16
+ }
17
+
18
+ const TEMPLATE_INFO: TemplateMeta[] = [
19
+ {
20
+ key: "act_hidden_works",
21
+ title: "АОСР — акты освидетельствования скрытых работ",
22
+ description: "Скрытые работы, подрядчик, заказчик, технадзор, оси.",
23
+ },
24
+ {
25
+ key: "act_osmotr_responsibility",
26
+ title: "Акт осмотра ответственных конструкций",
27
+ description: "Ответственные конструкции — приёмка, исполнители.",
28
+ },
29
+ {
30
+ key: "general_work_log",
31
+ title: "Общий журнал работ",
32
+ description: "Сводный журнал, ежедневная запись работ.",
33
+ },
34
+ {
35
+ key: "material_certificate",
36
+ title: "Сертификат / паспорт на материал",
37
+ description: "Паспорт на материал — соответствие, объём, партия.",
38
+ },
39
  ];
40
 
41
  export default function DocumentsPage() {
42
+ return (
43
+ <Suspense fallback={<main className="mx-auto max-w-md px-4 py-8" />}>
44
+ <DocumentsInner />
45
+ </Suspense>
46
+ );
47
+ }
48
+
49
+ function DocumentsInner() {
50
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
51
+ const { useSearchParams } = require("next/navigation") as typeof import("next/navigation");
52
+ const params = useSearchParams();
53
+ const initial_project = params?.get("project_id") || "";
54
+
55
+ const [templates, setTemplates] = useState<string[]>([]);
56
+ const [projects, setProjects] = useState<Project[]>([]);
57
+ const [projectId, setProjectId] = useState<string>(initial_project);
58
+ const [template, setTemplate] = useState<string>(TEMPLATE_INFO[0].key);
59
+ const [loading, setLoading] = useState(false);
60
+ const [error, setError] = useState<string | null>(null);
61
+ const [lastDownload, setLastDownload] = useState<{ name: string } | null>(
62
+ null
63
+ );
64
+ const [boilerplate, setBoilerplate] = useState<Record<string, string>>({
65
+ object_name_short: "",
66
+ work_description: "",
67
+ axes: "",
68
+ });
69
+
70
+ useEffect(() => {
71
+ listDocumentTemplates()
72
+ .then((r) => setTemplates(r.supported))
73
+ .catch(() => {});
74
+ listProjects()
75
+ .then((rows) => setProjects(rows))
76
+ .catch(() => {});
77
+ }, []);
78
+
79
+ const visibleTemplates = useMemo(
80
+ () => TEMPLATE_INFO.filter((t) => templates.includes(t.key)),
81
+ [templates]
82
+ );
83
+
84
+ const submit = async (e: React.FormEvent) => {
85
+ e.preventDefault();
86
+ if (!projectId || !template) {
87
+ setError("Выберите проект и шаблон.");
88
+ return;
89
+ }
90
+ setError(null);
91
+ setLastDownload(null);
92
+ setLoading(true);
93
+ try {
94
+ const blob = await renderDocument(template, projectId, boilerplate);
95
+ const filename = `${template}_${projectId.slice(0, 8)}.docx`;
96
+ const url = URL.createObjectURL(blob);
97
+ const a = document.createElement("a");
98
+ a.href = url;
99
+ a.download = filename;
100
+ a.click();
101
+ URL.revokeObjectURL(url);
102
+ setLastDownload({ name: filename });
103
+ } catch (err) {
104
+ setError(err instanceof Error ? err.message : "Неизвестная ошибка");
105
+ } finally {
106
+ setLoading(false);
107
+ }
108
+ };
109
+
110
  return (
111
  <main className="mx-auto max-w-md px-4 py-8">
112
+ <h1 className="mb-2 text-2xl font-bold text-foreman">
113
+ Сформировать документ
114
+ </h1>
115
+ <p className="mb-6 text-sm text-slate-600">
116
+ Выберите проект, шаблон и заполните минимум полей .docx скачается
117
+ автоматически.
118
+ </p>
119
+
120
+ <form onSubmit={submit} className="space-y-4">
121
+ <label className="block">
122
+ <span className="mb-1 block text-sm font-medium text-slate-700">
123
+ Проект
124
+ </span>
125
+ <select
126
+ required
127
+ value={projectId}
128
+ onChange={(e) => setProjectId(e.target.value)}
129
+ className="w-full rounded-xl border border-slate-300 bg-white px-3 py-3 outline-none focus:border-foreman"
130
  >
131
+ <option value="">— выберите —</option>
132
+ {projects.map((p) => (
133
+ <option key={p.id} value={p.id}>
134
+ {p.object_name} · {p.contract_no}
135
+ </option>
136
+ ))}
137
+ </select>
138
+ </label>
139
+
140
+ <label className="block">
141
+ <span className="mb-1 block text-sm font-medium text-slate-700">
142
+ Шаблон
143
+ </span>
144
+ <select
145
+ value={template}
146
+ onChange={(e) => setTemplate(e.target.value)}
147
+ className="w-full rounded-xl border border-slate-300 bg-white px-3 py-3 outline-none focus:border-foreman"
148
+ >
149
+ {visibleTemplates.map((t) => (
150
+ <option key={t.key} value={t.key}>
151
+ {t.title}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ </label>
156
+
157
+ <div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
158
+ {visibleTemplates.find((t) => t.key === template)?.description}
159
+ </div>
160
+
161
+ <fieldset className="space-y-3 rounded-xl border border-slate-200 bg-white p-3">
162
+ <legend className="px-1 text-xs font-semibold uppercase text-slate-500">
163
+ Контекст (опц.)
164
+ </legend>
165
+ {Object.entries(boilerplate).map(([k, v]) => (
166
+ <label key={k} className="block">
167
+ <span className="mb-1 block text-sm font-medium text-slate-700">
168
+ {k.replaceAll("_", " ")}
169
+ </span>
170
+ <input
171
+ value={v}
172
+ onChange={(e) =>
173
+ setBoilerplate((s) => ({ ...s, [k]: e.target.value }))
174
+ }
175
+ className="w-full rounded-lg border border-slate-300 px-3 py-2 outline-none focus:border-foreman"
176
+ />
177
+ </label>
178
+ ))}
179
+ </fieldset>
180
+
181
+ <button
182
+ type="submit"
183
+ disabled={loading}
184
+ className="w-full rounded-xl bg-foreman py-3 font-semibold text-white shadow hover:bg-foreman-dark disabled:bg-slate-400"
185
+ >
186
+ {loading ? "Рендерим..." : "Скачать .docx"}
187
+ </button>
188
+ </form>
189
+
190
+ {lastDownload && (
191
+ <div className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
192
+ Документ скачан: <code className="font-mono">{lastDownload.name}</code>
193
+ </div>
194
+ )}
195
+
196
+ {error && (
197
+ <div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
198
+ {error}
199
+ </div>
200
+ )}
201
+
202
+ <footer className="mt-10 flex justify-between">
203
+ <a href="/" className="text-xs text-slate-400 hover:text-slate-600">
204
+ ← на главную
205
+ </a>
206
+ <a
207
+ href="/project"
208
+ className="text-xs text-foreman hover:text-foreman-dark"
209
+ >
210
+ Все проекты →
211
+ </a>
212
+ </footer>
213
  </main>
214
  );
215
  }
app/history/page.tsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Suspense } from "react";
4
+ import { useEffect, useState } from "react";
5
+ import {
6
+ InferenceHistoryRow,
7
+ Project,
8
+ listInferenceHistory,
9
+ listProjects,
10
+ } from "@/lib/api";
11
+
12
+ interface FlatPayload {
13
+ inferred_summary?: string;
14
+ project_hint?: string;
15
+ steps?: Array<{ order: number; title: string }>;
16
+ sheets?: Array<{ sheet_number: string; title: string }>;
17
+ missing_info_prompts?: string[];
18
+ }
19
+
20
+ function brief(payload: unknown, source_text: string | null): string {
21
+ if (source_text && source_text.length > 0) return source_text;
22
+ if (payload && typeof payload === "object") {
23
+ const p = payload as FlatPayload;
24
+ return p.inferred_summary || p.project_hint || JSON.stringify(p).slice(0, 200);
25
+ }
26
+ return "(empty)";
27
+ }
28
+
29
+ export default function HistoryPage() {
30
+ return (
31
+ <Suspense fallback={<main className="mx-auto max-w-md px-4 py-8" />}>
32
+ <HistoryInner />
33
+ </Suspense>
34
+ );
35
+ }
36
+
37
+ function HistoryInner() {
38
+ const { useSearchParams } = require("next/navigation") as typeof import("next/navigation");
39
+ const params = useSearchParams();
40
+ const initial_project = params?.get("project_id") || "";
41
+ const [scope, setScope] = useState<string>(initial_project);
42
+ const [projects, setProjects] = useState<Project[]>([]);
43
+ const [rows, setRows] = useState<InferenceHistoryRow[] | null>(null);
44
+ const [error, setError] = useState<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ listProjects()
48
+ .then((p) => setProjects(p))
49
+ .catch(() => {});
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ setRows(null);
54
+ setError(null);
55
+ listInferenceHistory(scope || undefined)
56
+ .then((r) => setRows(r))
57
+ .catch((e) => setError(e instanceof Error ? e.message : "error"));
58
+ }, [scope]);
59
+
60
+ const filteredRows = scope
61
+ ? rows
62
+ : rows; // show all
63
+
64
+ return (
65
+ <main className="mx-auto max-w-md px-4 py-8">
66
+ <h1 className="mb-2 text-2xl font-bold text-foreman">
67
+ История инференсов
68
+ </h1>
69
+ <p className="mb-6 text-sm text-slate-600">
70
+ Сводка последних тех-стеков и чертежей, отправленных на ИИ.
71
+ </p>
72
+
73
+ <label className="mb-4 block">
74
+ <span className="mb-1 block text-sm font-medium text-slate-700">
75
+ Фильтр по проекту
76
+ </span>
77
+ <select
78
+ value={scope}
79
+ onChange={(e) => setScope(e.target.value)}
80
+ className="w-full rounded-xl border border-slate-300 bg-white px-3 py-3 outline-none focus:border-foreman"
81
+ >
82
+ <option value="">— все проекты —</option>
83
+ {projects.map((p) => (
84
+ <option key={p.id} value={p.id}>
85
+ {p.object_name} · {p.contract_no}
86
+ </option>
87
+ ))}
88
+ </select>
89
+ </label>
90
+
91
+ {error && (
92
+ <div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
93
+ {error}
94
+ </div>
95
+ )}
96
+
97
+ {rows === null && (
98
+ <p className="text-sm text-slate-400">Загрузка...</p>
99
+ )}
100
+ {rows && rows.length === 0 && (
101
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 text-sm text-slate-600">
102
+ Записей нет. Проверьте, что Supabase настроен и таблица
103
+ <code className="mx-1 rounded bg-slate-100 px-1.5 py-0.5 text-xs">
104
+ inference_history
105
+ </code>
106
+ создана.
107
+ </div>
108
+ )}
109
+
110
+ {filteredRows && filteredRows.length > 0 && (
111
+ <ul className="space-y-3">
112
+ {filteredRows.map((row) => {
113
+ const created = new Date(row.created_at);
114
+ return (
115
+ <li
116
+ key={row.id}
117
+ className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
118
+ >
119
+ <div className="flex items-center justify-between">
120
+ <span className="text-[11px] font-mono text-slate-500">
121
+ {created.toLocaleString("ru-RU")}
122
+ </span>
123
+ <span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
124
+ {row.model_used || "stub"}
125
+ </span>
126
+ </div>
127
+ <p className="mt-2 text-sm text-slate-900">
128
+ {brief(row.payload, row.source_text)}
129
+ </p>
130
+ {Array.isArray(
131
+ (row.payload as FlatPayload | null)?.steps
132
+ ) && (
133
+ <ul className="mt-2 text-xs text-slate-700">
134
+ {((row.payload as FlatPayload).steps || []).map((s) => (
135
+ <li key={s.order}>
136
+ · <span className="font-semibold">{s.title}</span>
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ )}
141
+ {Array.isArray(
142
+ (row.payload as FlatPayload | null)?.sheets
143
+ ) && (
144
+ <ul className="mt-2 text-xs text-slate-700">
145
+ {((row.payload as FlatPayload).sheets || []).map((s) => (
146
+ <li key={s.sheet_number}>
147
+ <span className="font-mono">{s.sheet_number}</span> ·{" "}
148
+ {s.title}
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ )}
153
+ </li>
154
+ );
155
+ })}
156
+ </ul>
157
+ )}
158
+
159
+ <footer className="mt-10">
160
+ <a
161
+ href="/"
162
+ className="block text-center text-xs text-slate-400 hover:text-slate-600"
163
+ >
164
+ ← на главную
165
+ </a>
166
+ </footer>
167
+ </main>
168
+ );
169
+ }
app/main.py CHANGED
@@ -1,13 +1,21 @@
1
  """FastAPI application entrypoint for Hugging Face Spaces."""
2
 
3
  from contextlib import asynccontextmanager
 
 
4
 
5
- from fastapi import FastAPI
6
  from fastapi.middleware.cors import CORSMiddleware
 
7
 
8
  from app import __version__
9
  from app.core.config import get_settings
10
- from app.routers import documents_router, projects_router, work_router
 
 
 
 
 
11
  from app.services.llm import close_llm_client
12
  from app.services.supabase_service import get_supabase_service
13
 
@@ -47,16 +55,43 @@ app = FastAPI(
47
 
48
  app.add_middleware(
49
  CORSMiddleware,
50
- allow_origins=["*"],
 
 
 
 
 
 
51
  allow_credentials=True,
52
- allow_methods=["*"],
53
  allow_headers=["*"],
54
  )
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # Routers
57
  app.include_router(projects_router)
58
  app.include_router(work_router)
59
  app.include_router(documents_router)
 
60
 
61
 
62
  @app.get("/health")
@@ -77,12 +112,16 @@ async def root() -> dict:
77
  "health": "/health",
78
  "supabase": "/supabase/health",
79
  "endpoints": [
80
- "POST /projects",
81
- "POST /work/infer",
82
- "POST /work/blueprint",
83
- "GET /documents/templates",
84
- "POST /documents/render",
85
- "GET /supabase/health",
 
 
 
 
86
  ],
87
  }
88
 
 
1
  """FastAPI application entrypoint for Hugging Face Spaces."""
2
 
3
  from contextlib import asynccontextmanager
4
+ import time
5
+ import uuid
6
 
7
+ from fastapi import FastAPI, Request
8
  from fastapi.middleware.cors import CORSMiddleware
9
+ from loguru import logger
10
 
11
  from app import __version__
12
  from app.core.config import get_settings
13
+ from app.routers import (
14
+ documents_router,
15
+ inference_router,
16
+ projects_router,
17
+ work_router,
18
+ )
19
  from app.services.llm import close_llm_client
20
  from app.services.supabase_service import get_supabase_service
21
 
 
55
 
56
  app.add_middleware(
57
  CORSMiddleware,
58
+ allow_origins=[
59
+ "https://virt-foreman.vercel.app",
60
+ "https://dirbol-virtforemanbackend.hf.space",
61
+ "http://localhost:3000",
62
+ "http://127.0.0.1:3000",
63
+ ],
64
+ allow_origin_regex=r"^https://[a-z0-9-]+\.vercel\.app$",
65
  allow_credentials=True,
66
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
67
  allow_headers=["*"],
68
  )
69
 
70
+
71
+ @app.middleware("http")
72
+ async def request_id_middleware(request: Request, call_next):
73
+ """Attach a stable X-Request-ID to every request/response and
74
+ log the basic access line. Useful for end-to-end tracing against
75
+ the Space's runtime log.
76
+ """
77
+ rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
78
+ request.state.request_id = rid
79
+ started = time.monotonic()
80
+ response = await call_next(request)
81
+ elapsed = (time.monotonic() - started) * 1000
82
+ response.headers["X-Request-ID"] = rid
83
+ logger.info(
84
+ f"[http] {rid} {request.method} {request.url.path} "
85
+ f"-> {response.status_code} in {elapsed:.0f}ms"
86
+ )
87
+ return response
88
+
89
+
90
  # Routers
91
  app.include_router(projects_router)
92
  app.include_router(work_router)
93
  app.include_router(documents_router)
94
+ app.include_router(inference_router)
95
 
96
 
97
  @app.get("/health")
 
112
  "health": "/health",
113
  "supabase": "/supabase/health",
114
  "endpoints": [
115
+ "POST /projects",
116
+ "GET /projects",
117
+ "GET /projects/{project_id}",
118
+ "DELETE /projects/{project_id}",
119
+ "POST /work/infer",
120
+ "POST /work/blueprint",
121
+ "GET /documents/templates",
122
+ "POST /documents/render",
123
+ "GET /inference/history",
124
+ "GET /supabase/health",
125
  ],
126
  }
127
 
app/models/tech_stack.py CHANGED
@@ -12,14 +12,21 @@ class DailyWorkInput(BaseModel):
12
  raw_text: str = Field(
13
  ...,
14
  min_length=3,
 
15
  description="Casual statement by the user (RU/EN), e.g. 'Залита плита перекрытия А-В/1-3'",
16
  )
17
  date_from: date
18
  date_to: date
19
  axes: Optional[str] = Field(
20
  default=None,
 
21
  description="Grid axes mentioned, e.g. 'А-В / 1-3'",
22
  )
 
 
 
 
 
23
 
24
 
25
  class TechStep(BaseModel):
 
12
  raw_text: str = Field(
13
  ...,
14
  min_length=3,
15
+ max_length=4000,
16
  description="Casual statement by the user (RU/EN), e.g. 'Залита плита перекрытия А-В/1-3'",
17
  )
18
  date_from: date
19
  date_to: date
20
  axes: Optional[str] = Field(
21
  default=None,
22
+ max_length=200,
23
  description="Grid axes mentioned, e.g. 'А-В / 1-3'",
24
  )
25
+ project_id: Optional[str] = Field(
26
+ default=None,
27
+ max_length=64,
28
+ description="Optional project_uuid — wires inference_history rows",
29
+ )
30
 
31
 
32
  class TechStep(BaseModel):
app/page.tsx CHANGED
@@ -47,6 +47,12 @@ export default function HomePage() {
47
  >
48
  Новый проект
49
  </a>
 
 
 
 
 
 
50
  <a
51
  href="/work"
52
  className="mt-3 block w-full rounded-xl border border-foreman bg-white py-3 text-center font-semibold text-foreman hover:bg-slate-50"
@@ -65,6 +71,12 @@ export default function HomePage() {
65
  >
66
  Документы и шаблоны
67
  </a>
 
 
 
 
 
 
68
  </section>
69
 
70
  <footer className="mt-12 text-center text-xs text-slate-400">
 
47
  >
48
  Новый проект
49
  </a>
50
+ <a
51
+ href="/project"
52
+ className="mt-3 block w-full rounded-xl border border-slate-300 bg-white py-3 text-center text-sm font-semibold text-slate-700 hover:bg-slate-50"
53
+ >
54
+ Все проекты
55
+ </a>
56
  <a
57
  href="/work"
58
  className="mt-3 block w-full rounded-xl border border-foreman bg-white py-3 text-center font-semibold text-foreman hover:bg-slate-50"
 
71
  >
72
  Документы и шаблоны
73
  </a>
74
+ <a
75
+ href="/history"
76
+ className="mt-3 block w-full rounded-xl border border-amber-300 bg-white py-3 text-center text-sm font-semibold text-amber-800 hover:bg-amber-50"
77
+ >
78
+ История инференсов
79
+ </a>
80
  </section>
81
 
82
  <footer className="mt-12 text-center text-xs text-slate-400">
app/project/new/page.tsx CHANGED
@@ -1,36 +1,77 @@
1
  "use client";
2
 
3
  import { useState } from "react";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export default function NewProjectPage() {
6
- const [form, setForm] = useState({
7
- contract_no: "",
8
- object_name: "",
9
- client: "",
10
- technical_supervisor: "",
11
- contractor: "",
12
- work_producer: "",
13
- chief_engineer: "",
14
- });
15
- const [submitted, setSubmitted] = useState(false);
16
 
17
- const handle = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
18
- setForm({ ...form, [k]: e.target.value });
 
 
19
 
20
  const submit = async (e: React.FormEvent) => {
21
  e.preventDefault();
22
- // Out of scope for Phase 4 persistence — currently local-only
23
- // Phase 5 will wire to POST /projects once Supabase is reachable from frontend.
24
- setSubmitted(true);
 
 
 
 
 
 
 
 
25
  };
26
 
27
- const fields: { key: keyof typeof form; label: string; placeholder?: string }[] = [
28
- { key: "contract_no", label: "Договор №", placeholder: "12-2026/БН" },
29
- { key: "object_name", label: "Объект", placeholder: "Жилой дом по ул. ..." },
 
 
 
 
 
 
 
 
 
 
 
 
30
  { key: "client", label: "Заказчик" },
31
  { key: "technical_supervisor", label: "Технадзор" },
32
  { key: "contractor", label: "Подрядчик" },
33
- { key: "work_producer", label: "Производитель работ (прораб)" },
 
 
 
34
  { key: "chief_engineer", label: "Главный инженер" },
35
  ];
36
 
@@ -38,19 +79,60 @@ export default function NewProjectPage() {
38
  <main className="mx-auto max-w-md px-4 py-8">
39
  <h1 className="mb-6 text-2xl font-bold text-foreman">Новый проект</h1>
40
 
41
- {submitted ? (
42
- <div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
43
- <p className="font-semibold text-emerald-700">Проект сохранён (локально)</p>
44
- <p className="mt-2 text-sm text-slate-700">Полная синхронизация с Supabase появится в следующем релизе.</p>
45
- <a href="/work" className="mt-4 inline-block text-sm font-semibold text-foreman">
46
- → Перейти к ежедневным записям
47
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </div>
49
  ) : (
50
  <form onSubmit={submit} className="space-y-4">
51
  {fields.map((f) => (
52
  <label key={f.key} className="block">
53
- <span className="mb-1 block text-sm font-medium text-slate-700">{f.label}</span>
 
 
54
  <input
55
  required
56
  value={form[f.key]}
@@ -62,12 +144,31 @@ export default function NewProjectPage() {
62
  ))}
63
  <button
64
  type="submit"
65
- className="w-full rounded-xl bg-foreman py-3 font-semibold text-white shadow hover:bg-foreman-dark"
 
66
  >
67
- Создать проект
68
  </button>
69
  </form>
70
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </main>
72
  );
73
  }
 
1
  "use client";
2
 
3
  import { useState } from "react";
4
+ import Link from "next/link";
5
+ import { createProject, Project } from "@/lib/api";
6
+
7
+ type FormState = {
8
+ contract_no: string;
9
+ object_name: string;
10
+ client: string;
11
+ technical_supervisor: string;
12
+ contractor: string;
13
+ work_producer: string;
14
+ chief_engineer: string;
15
+ };
16
+
17
+ const initial: FormState = {
18
+ contract_no: "",
19
+ object_name: "",
20
+ client: "",
21
+ technical_supervisor: "",
22
+ contractor: "",
23
+ work_producer: "",
24
+ chief_engineer: "",
25
+ };
26
 
27
  export default function NewProjectPage() {
28
+ const [form, setForm] = useState<FormState>(initial);
29
+ const [loading, setLoading] = useState(false);
30
+ const [project, setProject] = useState<Project | null>(null);
31
+ const [error, setError] = useState<string | null>(null);
 
 
 
 
 
 
32
 
33
+ const handle =
34
+ <K extends keyof FormState>(k: K) =>
35
+ (e: React.ChangeEvent<HTMLInputElement>) =>
36
+ setForm((s) => ({ ...s, [k]: e.target.value }));
37
 
38
  const submit = async (e: React.FormEvent) => {
39
  e.preventDefault();
40
+ setError(null);
41
+ setProject(null);
42
+ setLoading(true);
43
+ try {
44
+ const resp = await createProject(form);
45
+ setProject(resp);
46
+ } catch (err) {
47
+ setError(err instanceof Error ? err.message : "Неизвестная ошибка");
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
  };
52
 
53
+ const fields: {
54
+ key: keyof FormState;
55
+ label: string;
56
+ placeholder?: string;
57
+ }[] = [
58
+ {
59
+ key: "contract_no",
60
+ label: "Договор №",
61
+ placeholder: "12-2026/БН",
62
+ },
63
+ {
64
+ key: "object_name",
65
+ label: "Объект",
66
+ placeholder: "Жилой дом по ул. ...",
67
+ },
68
  { key: "client", label: "Заказчик" },
69
  { key: "technical_supervisor", label: "Технадзор" },
70
  { key: "contractor", label: "Подрядчик" },
71
+ {
72
+ key: "work_producer",
73
+ label: "Производитель работ (прораб)",
74
+ },
75
  { key: "chief_engineer", label: "Главный инженер" },
76
  ];
77
 
 
79
  <main className="mx-auto max-w-md px-4 py-8">
80
  <h1 className="mb-6 text-2xl font-bold text-foreman">Новый проект</h1>
81
 
82
+ {project ? (
83
+ <div className="space-y-4">
84
+ <div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
85
+ <p className="font-semibold text-emerald-700">
86
+ Проект создан
87
+ </p>
88
+ <p className="mt-2 text-sm text-slate-700">
89
+ {project.object_name} (договор {project.contract_no})
90
+ </p>
91
+ <p className="mt-1 text-[11px] font-mono text-slate-500">
92
+ id: {project.id}
93
+ </p>
94
+ {project.warning && (
95
+ <p className="mt-2 text-xs text-amber-700">{project.warning}</p>
96
+ )}
97
+ </div>
98
+ <div className="grid grid-cols-1 gap-3">
99
+ <Link
100
+ href={`/work?project_id=${project.id}`}
101
+ className="block w-full rounded-xl bg-foreman py-3 text-center font-semibold text-white shadow hover:bg-foreman-dark"
102
+ >
103
+ → Запись о работе
104
+ </Link>
105
+ <Link
106
+ href={`/blueprint?project_id=${project.id}`}
107
+ className="block w-full rounded-xl border border-foreman bg-white py-3 text-center font-semibold text-foreman hover:bg-slate-50"
108
+ >
109
+ → Загрузить чертёж
110
+ </Link>
111
+ <Link
112
+ href={`/documents?project_id=${project.id}`}
113
+ className="block w-full rounded-xl border border-slate-300 bg-white py-3 text-center font-semibold text-slate-700 hover:bg-slate-50"
114
+ >
115
+ → Сформировать документ
116
+ </Link>
117
+ </div>
118
+ <button
119
+ type="button"
120
+ onClick={() => {
121
+ setProject(null);
122
+ setForm(initial);
123
+ }}
124
+ className="w-full text-xs text-slate-400 hover:text-slate-600"
125
+ >
126
+ ← ещё один проект
127
+ </button>
128
  </div>
129
  ) : (
130
  <form onSubmit={submit} className="space-y-4">
131
  {fields.map((f) => (
132
  <label key={f.key} className="block">
133
+ <span className="mb-1 block text-sm font-medium text-slate-700">
134
+ {f.label}
135
+ </span>
136
  <input
137
  required
138
  value={form[f.key]}
 
144
  ))}
145
  <button
146
  type="submit"
147
+ disabled={loading}
148
+ className="w-full rounded-xl bg-foreman py-3 font-semibold text-white shadow hover:bg-foreman-dark disabled:bg-slate-400"
149
  >
150
+ {loading ? "Создание..." : "Создать проект"}
151
  </button>
152
  </form>
153
  )}
154
+
155
+ {error && (
156
+ <div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
157
+ {error}
158
+ </div>
159
+ )}
160
+
161
+ <footer className="mt-10 flex justify-between">
162
+ <Link href="/" className="text-xs text-slate-400 hover:text-slate-600">
163
+ ← на главную
164
+ </Link>
165
+ <Link
166
+ href="/project"
167
+ className="text-xs text-foreman hover:text-foreman-dark"
168
+ >
169
+ Все проекты →
170
+ </Link>
171
+ </footer>
172
  </main>
173
  );
174
  }
app/project/page.tsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useState } from "react";
5
+ import { Project, listProjects } from "@/lib/api";
6
+
7
+ export default function ProjectsListPage() {
8
+ const [items, setItems] = useState<Project[] | null>(null);
9
+ const [error, setError] = useState<string | null>(null);
10
+
11
+ useEffect(() => {
12
+ listProjects()
13
+ .then((rows) => setItems(rows))
14
+ .catch((e) => setError(e instanceof Error ? e.message : "error"));
15
+ }, []);
16
+
17
+ return (
18
+ <main className="mx-auto max-w-md px-4 py-8">
19
+ <header className="mb-6 flex items-center justify-between">
20
+ <h1 className="text-2xl font-bold text-foreman">Проекты</h1>
21
+ <Link
22
+ href="/project/new"
23
+ className="rounded-xl bg-foreman px-3 py-2 text-sm font-semibold text-white shadow hover:bg-foreman-dark"
24
+ >
25
+ + Новый
26
+ </Link>
27
+ </header>
28
+
29
+ {items === null && (
30
+ <p className="text-sm text-slate-400">Загрузка...</p>
31
+ )}
32
+ {error && (
33
+ <div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
34
+ {error}
35
+ </div>
36
+ )}
37
+ {items && items.length === 0 && (
38
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 text-sm text-slate-600">
39
+ Пока нет проектов. Создайте первый.
40
+ </div>
41
+ )}
42
+ {items && items.length > 0 && (
43
+ <ul className="space-y-2">
44
+ {items.map((p) => (
45
+ <li
46
+ key={p.id}
47
+ className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
48
+ >
49
+ <Link
50
+ href={`/project/new?reuse=${p.id}`}
51
+ className="block hover:opacity-90"
52
+ >
53
+ <div className="flex items-start justify-between gap-3">
54
+ <div className="min-w-0">
55
+ <p className="truncate text-sm font-semibold text-slate-900">
56
+ {p.object_name || "(без названия)"}
57
+ </p>
58
+ <p className="text-xs text-slate-500">
59
+ Договор {p.contract_no || "—"} · {p.client || "—"}
60
+ </p>
61
+ </div>
62
+ <code className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">
63
+ {p.id.slice(0, 8)}
64
+ </code>
65
+ </div>
66
+ </Link>
67
+ <div className="mt-3 flex flex-wrap gap-1.5">
68
+ <Link
69
+ href={`/work?project_id=${p.id}`}
70
+ className="rounded-md bg-foreman/10 px-2 py-1 text-[11px] font-medium text-foreman hover:bg-foreman/20"
71
+ >
72
+ Работа
73
+ </Link>
74
+ <Link
75
+ href={`/blueprint?project_id=${p.id}`}
76
+ className="rounded-md bg-foreman/10 px-2 py-1 text-[11px] font-medium text-foreman hover:bg-foreman/20"
77
+ >
78
+ Чертёж
79
+ </Link>
80
+ <Link
81
+ href={`/documents?project_id=${p.id}`}
82
+ className="rounded-md bg-foreman/10 px-2 py-1 text-[11px] font-medium text-foreman hover:bg-foreman/20"
83
+ >
84
+ Документ
85
+ </Link>
86
+ <Link
87
+ href={`/history?project_id=${p.id}`}
88
+ className="rounded-md bg-amber-100 px-2 py-1 text-[11px] font-medium text-amber-800 hover:bg-amber-200"
89
+ >
90
+ История
91
+ </Link>
92
+ </div>
93
+ </li>
94
+ ))}
95
+ </ul>
96
+ )}
97
+
98
+ <footer className="mt-10">
99
+ <Link href="/" className="block text-center text-xs text-slate-400 hover:text-slate-600">
100
+ ← на главную
101
+ </Link>
102
+ </footer>
103
+ </main>
104
+ );
105
+ }
app/routers/__init__.py CHANGED
@@ -1,7 +1,13 @@
1
  """API routers package."""
2
 
3
  from app.routers.documents import router as documents_router
 
4
  from app.routers.projects import router as projects_router
5
  from app.routers.work import router as work_router
6
 
7
- __all__ = ["projects_router", "work_router", "documents_router"]
 
 
 
 
 
 
1
  """API routers package."""
2
 
3
  from app.routers.documents import router as documents_router
4
+ from app.routers.inference import router as inference_router
5
  from app.routers.projects import router as projects_router
6
  from app.routers.work import router as work_router
7
 
8
+ __all__ = [
9
+ "projects_router",
10
+ "work_router",
11
+ "documents_router",
12
+ "inference_router",
13
+ ]
app/routers/inference.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference history router — read recent /work/infer + /work/blueprint rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Query
8
+
9
+ from app.services.supabase_service import get_supabase_service
10
+
11
+ router = APIRouter(prefix="/inference", tags=["inference"])
12
+
13
+
14
+ @router.get("/history", response_model=List[dict])
15
+ async def list_history(
16
+ project_id: str | None = Query(default=None),
17
+ limit: int = Query(default=50, ge=1, le=200),
18
+ ) -> List[dict]:
19
+ sb = get_supabase_service()
20
+ return await sb.list_inference_history(
21
+ project_id=project_id,
22
+ limit=limit,
23
+ )
app/routers/projects.py CHANGED
@@ -1,11 +1,12 @@
1
- """Project CRUD router (skeleton Supabase optional)."""
2
 
3
  from __future__ import annotations
4
 
5
  import uuid
6
  from datetime import datetime, timezone
 
7
 
8
- from fastapi import APIRouter, HTTPException
9
 
10
  from app.models.project import Project, ProjectCreate
11
  from app.services.supabase_service import get_supabase_service
@@ -13,25 +14,60 @@ from app.services.supabase_service import get_supabase_service
13
  router = APIRouter(prefix="/projects", tags=["projects"])
14
 
15
 
16
- @router.post("", response_model=Project, status_code=201)
17
- async def create_project(payload: ProjectCreate) -> Project:
 
 
 
18
  sb = get_supabase_service()
19
  data = payload.model_dump(mode="json")
20
  project_id = str(uuid.uuid4())
21
  data["id"] = project_id
22
  data["created_at"] = datetime.now(timezone.utc).isoformat()
23
 
24
- await sb.create_project(data)
25
- return Project(**data)
 
 
 
 
 
 
 
 
26
 
27
 
28
- @router.get("/{project_id}", response_model=Project)
29
- async def get_project(project_id: str) -> Project:
 
 
30
  sb = get_supabase_service()
31
- if not sb.available:
32
- raise HTTPException(
33
- status_code=503,
34
- detail="Supabase unavailable in this environment",
35
- )
36
- # Minimal implementation — full CRUD out of scope for Phase 3
37
- raise HTTPException(status_code=501, detail="Not fully implemented yet")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Project CRUD router (full Supabase end-to-end)."""
2
 
3
  from __future__ import annotations
4
 
5
  import uuid
6
  from datetime import datetime, timezone
7
+ from typing import List
8
 
9
+ from fastapi import APIRouter, HTTPException, Query
10
 
11
  from app.models.project import Project, ProjectCreate
12
  from app.services.supabase_service import get_supabase_service
 
14
  router = APIRouter(prefix="/projects", tags=["projects"])
15
 
16
 
17
+ @router.post("", response_model=dict)
18
+ async def create_project(payload: ProjectCreate) -> dict:
19
+ """Persist project in Supabase. Returns the row, optionally with a
20
+ backend-generated id when client didn't provide one.
21
+ """
22
  sb = get_supabase_service()
23
  data = payload.model_dump(mode="json")
24
  project_id = str(uuid.uuid4())
25
  data["id"] = project_id
26
  data["created_at"] = datetime.now(timezone.utc).isoformat()
27
 
28
+ persisted = await sb.create_project(data)
29
+ if persisted is None:
30
+ # Supabase unreachable — return the in-memory record anyway so
31
+ # the client can move forward without losing context.
32
+ return {
33
+ **data,
34
+ "persisted": False,
35
+ "warning": "Supabase unavailable — record kept in-memory only",
36
+ }
37
+ return {**persisted, "persisted": True}
38
 
39
 
40
+ @router.get("", response_model=List[dict])
41
+ async def list_projects(
42
+ limit: int = Query(default=50, ge=1, le=200),
43
+ ) -> List[dict]:
44
  sb = get_supabase_service()
45
+ return await sb.list_projects(limit=limit)
46
+
47
+
48
+ @router.get("/{project_id}")
49
+ async def get_project(project_id: str) -> dict:
50
+ sb = get_supabase_service()
51
+ row = await sb.get_project(project_id)
52
+ if not row:
53
+ if not sb.available:
54
+ raise HTTPException(
55
+ status_code=503,
56
+ detail="Supabase unavailable in this environment",
57
+ )
58
+ raise HTTPException(status_code=404, detail="project not found")
59
+ return row
60
+
61
+
62
+ @router.delete("/{project_id}", response_model=dict)
63
+ async def delete_project(project_id: str) -> dict:
64
+ sb = get_supabase_service()
65
+ ok = await sb.delete_project(project_id)
66
+ if not ok:
67
+ if not sb.available:
68
+ raise HTTPException(
69
+ status_code=503,
70
+ detail="Supabase unavailable in this environment",
71
+ )
72
+ raise HTTPException(status_code=404, detail="project not found")
73
+ return {"deleted": True, "id": project_id}
app/routers/work.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
5
- from fastapi import APIRouter, File, HTTPException, UploadFile
6
 
7
  from app.models.blueprint import BlueprintInferenceResponse
8
  from app.models.tech_stack import DailyWorkInput, InferredWorkResponse
@@ -13,6 +13,8 @@ from app.services.tech_stack_service import infer_tech_stack
13
 
14
  router = APIRouter(prefix="/work", tags=["work"])
15
 
 
 
16
 
17
  @router.post("/infer", response_model=InferredWorkResponse)
18
  async def infer_endpoint(payload: DailyWorkInput) -> InferredWorkResponse:
@@ -21,13 +23,12 @@ async def infer_endpoint(payload: DailyWorkInput) -> InferredWorkResponse:
21
  except Exception as exc: # noqa: BLE001
22
  # Defensive: inference now always returns *something* (model or
23
  # stub), so a raise here is genuinely unexpected.
24
- from fastapi import HTTPException
25
  raise HTTPException(status_code=502, detail=str(exc)) from exc
26
 
27
  # Best-effort persistence to Supabase (fire-and-forget).
28
  sb = get_supabase_service()
29
  await sb.record_inference(
30
- project_id=None, # project context wired in Phase 4 frontend
31
  request_id=response.request_id,
32
  source_text=payload.raw_text,
33
  tech_stack=response.tech_stack.model_dump(mode="json"),
@@ -37,8 +38,15 @@ async def infer_endpoint(payload: DailyWorkInput) -> InferredWorkResponse:
37
 
38
 
39
  @router.post("/blueprint", response_model=BlueprintInferenceResponse)
40
- async def blueprint_endpoint(file: UploadFile = File(...)) -> BlueprintInferenceResponse:
41
- """Accept blueprint image/PDF (multipart/form-data) and extract metadata."""
 
 
 
 
 
 
 
42
  if not file.filename:
43
  raise HTTPException(status_code=400, detail="filename required")
44
  mime = (file.content_type or "").lower()
@@ -62,6 +70,14 @@ async def blueprint_endpoint(file: UploadFile = File(...)) -> BlueprintInference
62
  data = await file.read()
63
  if not data:
64
  raise HTTPException(status_code=400, detail="empty upload")
 
 
 
 
 
 
 
 
65
 
66
  try:
67
  response = await extract_blueprint(
@@ -74,7 +90,7 @@ async def blueprint_endpoint(file: UploadFile = File(...)) -> BlueprintInference
74
 
75
  sb = get_supabase_service()
76
  await sb.record_inference(
77
- project_id=None,
78
  request_id=response.request_id,
79
  source_text=f"[blueprint upload] {file.filename} ({mime})",
80
  tech_stack=response.extraction.model_dump(mode="json"),
 
2
 
3
  from __future__ import annotations
4
 
5
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
6
 
7
  from app.models.blueprint import BlueprintInferenceResponse
8
  from app.models.tech_stack import DailyWorkInput, InferredWorkResponse
 
13
 
14
  router = APIRouter(prefix="/work", tags=["work"])
15
 
16
+ MAX_BLUEPRINT_BYTES = 50 * 1024 * 1024 # 50 MB — keeps Vision URLs reasonable
17
+
18
 
19
  @router.post("/infer", response_model=InferredWorkResponse)
20
  async def infer_endpoint(payload: DailyWorkInput) -> InferredWorkResponse:
 
23
  except Exception as exc: # noqa: BLE001
24
  # Defensive: inference now always returns *something* (model or
25
  # stub), so a raise here is genuinely unexpected.
 
26
  raise HTTPException(status_code=502, detail=str(exc)) from exc
27
 
28
  # Best-effort persistence to Supabase (fire-and-forget).
29
  sb = get_supabase_service()
30
  await sb.record_inference(
31
+ project_id=payload.project_id,
32
  request_id=response.request_id,
33
  source_text=payload.raw_text,
34
  tech_stack=response.tech_stack.model_dump(mode="json"),
 
38
 
39
 
40
  @router.post("/blueprint", response_model=BlueprintInferenceResponse)
41
+ async def blueprint_endpoint(
42
+ file: UploadFile = File(...),
43
+ project_id: str | None = Form(default=None),
44
+ ) -> BlueprintInferenceResponse:
45
+ """Accept blueprint image/PDF (multipart/form-data) and extract metadata.
46
+
47
+ Optional `project_id` form-field wires the inference into a
48
+ project-specific audit trail.
49
+ """
50
  if not file.filename:
51
  raise HTTPException(status_code=400, detail="filename required")
52
  mime = (file.content_type or "").lower()
 
70
  data = await file.read()
71
  if not data:
72
  raise HTTPException(status_code=400, detail="empty upload")
73
+ if len(data) > MAX_BLUEPRINT_BYTES:
74
+ raise HTTPException(
75
+ status_code=413,
76
+ detail=(
77
+ f"file too large ({len(data)} B > {MAX_BLUEPRINT_BYTES} B). "
78
+ "Reduce to ≤ 50 MB."
79
+ ),
80
+ )
81
 
82
  try:
83
  response = await extract_blueprint(
 
90
 
91
  sb = get_supabase_service()
92
  await sb.record_inference(
93
+ project_id=project_id,
94
  request_id=response.request_id,
95
  source_text=f"[blueprint upload] {file.filename} ({mime})",
96
  tech_stack=response.extraction.model_dump(mode="json"),
app/services/supabase_service.py CHANGED
@@ -34,7 +34,7 @@ from app.core.config import get_settings
34
 
35
  def project_to_supabase(payload: dict[str, Any]) -> dict[str, Any]:
36
  """Map canonical ProjectCreate → existing Supabase `projects` row."""
37
- out = {
38
  "name": payload["object_name"],
39
  "contract_number": payload["contract_no"],
40
  "client": payload["client"],
@@ -43,9 +43,12 @@ def project_to_supabase(payload: dict[str, Any]) -> dict[str, Any]:
43
  "responsible_foreman": payload["work_producer"],
44
  "chief_engineer": payload["chief_engineer"],
45
  }
46
- # Optional fields kept as-is only if the column supports them.
 
 
 
47
  # The pre-existing columns don't have start_date/end_date_estimated,
48
- # so we drop those.
49
  return out
50
 
51
 
@@ -120,6 +123,62 @@ class SupabaseService:
120
  logger.warning(f"[supabase] get_project failed: {exc}")
121
  return None
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  async def record_inference(
124
  self,
125
  *,
 
34
 
35
  def project_to_supabase(payload: dict[str, Any]) -> dict[str, Any]:
36
  """Map canonical ProjectCreate → existing Supabase `projects` row."""
37
+ out: dict[str, Any] = {
38
  "name": payload["object_name"],
39
  "contract_number": payload["contract_no"],
40
  "client": payload["client"],
 
43
  "responsible_foreman": payload["work_producer"],
44
  "chief_engineer": payload["chief_engineer"],
45
  }
46
+ # Pass-through optional interface-managed columns when supplied.
47
+ for k in ("id", "created_at"):
48
+ if k in payload and payload[k] is not None:
49
+ out[k] = payload[k]
50
  # The pre-existing columns don't have start_date/end_date_estimated,
51
+ # so we drop those (kept only in canonical).
52
  return out
53
 
54
 
 
123
  logger.warning(f"[supabase] get_project failed: {exc}")
124
  return None
125
 
126
+ async def list_projects(self, limit: int = 50) -> list[dict[str, Any]]:
127
+ """Return recent projects ordered by created_at desc."""
128
+ if not self.available:
129
+ return []
130
+ try:
131
+ result = (
132
+ self._client.table("projects")
133
+ .select("*")
134
+ .order("created_at", desc=True)
135
+ .limit(max(1, min(limit, 200)))
136
+ .execute()
137
+ )
138
+ return [project_from_supabase(r) for r in (result.data or [])]
139
+ except Exception as exc: # noqa: BLE001
140
+ logger.warning(f"[supabase] list_projects failed: {exc}")
141
+ return []
142
+
143
+ async def delete_project(self, project_id: str) -> bool:
144
+ """Remove a project row. Returns True if a row was removed."""
145
+ if not self.available:
146
+ return False
147
+ try:
148
+ result = (
149
+ self._client.table("projects")
150
+ .delete()
151
+ .eq("id", project_id)
152
+ .execute()
153
+ )
154
+ return bool(result.data)
155
+ except Exception as exc: # noqa: BLE001
156
+ logger.warning(f"[supabase] delete_project failed: {exc}")
157
+ return False
158
+
159
+ async def list_inference_history(
160
+ self,
161
+ *,
162
+ project_id: str | None = None,
163
+ limit: int = 50,
164
+ ) -> list[dict[str, Any]]:
165
+ """Read recent inference rows. Filter by `project_id` if provided."""
166
+ if not self.available:
167
+ return []
168
+ try:
169
+ q = self._client.table("inference_history").select("*")
170
+ if project_id:
171
+ q = q.eq("project_id", project_id)
172
+ result = (
173
+ q.order("created_at", desc=True)
174
+ .limit(max(1, min(limit, 200)))
175
+ .execute()
176
+ )
177
+ return list(result.data or [])
178
+ except Exception as exc: # noqa: BLE001
179
+ logger.warning(f"[supabase] list_inference_history failed: {exc}")
180
+ return []
181
+
182
  async def record_inference(
183
  self,
184
  *,
app/work/page.tsx CHANGED
@@ -1,11 +1,28 @@
1
  "use client";
2
 
 
3
  import { useState } from "react";
4
  import { inferWork, TechStack } from "@/lib/api";
5
 
6
  const EXAMPLE_PLACEHOLDER = "Например: «Залита плита перекрытия в осях А-В / 1-3»";
7
 
8
  export default function WorkWizardPage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  const today = new Date().toISOString().slice(0, 10);
10
  const [raw_text, setRawText] = useState("");
11
  const [date_from, setDateFrom] = useState(today);
@@ -21,7 +38,13 @@ export default function WorkWizardPage() {
21
  setResult(null);
22
  setLoading(true);
23
  try {
24
- const resp = await inferWork({ raw_text, date_from, date_to, axes });
 
 
 
 
 
 
25
  setResult(resp.tech_stack);
26
  } catch (err) {
27
  setError(err instanceof Error ? err.message : "Неизвестная ошибка");
@@ -37,6 +60,13 @@ export default function WorkWizardPage() {
37
  Напишите коротко, что сделано — ИИ развернёт в полный тех-стек.
38
  </p>
39
 
 
 
 
 
 
 
 
40
  <form onSubmit={submit} className="space-y-4">
41
  <label className="block">
42
  <span className="mb-1 block text-sm font-medium text-slate-700">Что сделано?</span>
 
1
  "use client";
2
 
3
+ import { Suspense } from "react";
4
  import { useState } from "react";
5
  import { inferWork, TechStack } from "@/lib/api";
6
 
7
  const EXAMPLE_PLACEHOLDER = "Например: «Залита плита перекрытия в осях А-В / 1-3»";
8
 
9
  export default function WorkWizardPage() {
10
+ // Wrap in Suspense so Next.js can hand us `useSearchParams` from
11
+ // searchParams without disabling SSG/strict-mode warnings.
12
+ return (
13
+ <Suspense fallback={<main className="mx-auto max-w-md px-4 py-8" />}>
14
+ <WorkWizardInner />
15
+ </Suspense>
16
+ );
17
+ }
18
+
19
+ function WorkWizardInner() {
20
+ // Lazy import useSearchParams from next/navigation inside the inner
21
+ // component so the page is allowed to use dynamic searchParams.
22
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
23
+ const { useSearchParams } = require("next/navigation") as typeof import("next/navigation");
24
+ const params = useSearchParams();
25
+ const project_id = params?.get("project_id") || "";
26
  const today = new Date().toISOString().slice(0, 10);
27
  const [raw_text, setRawText] = useState("");
28
  const [date_from, setDateFrom] = useState(today);
 
38
  setResult(null);
39
  setLoading(true);
40
  try {
41
+ const resp = await inferWork({
42
+ raw_text,
43
+ date_from,
44
+ date_to,
45
+ axes: axes || undefined,
46
+ project_id: project_id || undefined,
47
+ });
48
  setResult(resp.tech_stack);
49
  } catch (err) {
50
  setError(err instanceof Error ? err.message : "Неизвестная ошибка");
 
60
  Напишите коротко, что сделано — ИИ развернёт в полный тех-стек.
61
  </p>
62
 
63
+ {project_id && (
64
+ <div className="mb-4 rounded-xl border border-foreman/30 bg-foreman/5 px-3 py-2 text-xs text-foreman">
65
+ Привязано к проекту:{" "}
66
+ <code className="font-mono">{project_id.slice(0, 8)}…</code>
67
+ </div>
68
+ )}
69
+
70
  <form onSubmit={submit} className="space-y-4">
71
  <label className="block">
72
  <span className="mb-1 block text-sm font-medium text-slate-700">Что сделано?</span>
lib/api.ts CHANGED
@@ -68,6 +68,45 @@ export interface BlueprintInferenceResponse {
68
  request_id: string;
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  async function postJSON<T>(path: string, body: unknown): Promise<T> {
72
  const res = await fetch(`${API_URL}${path}`, {
73
  method: "POST",
@@ -81,12 +120,39 @@ async function postJSON<T>(path: string, body: unknown): Promise<T> {
81
  return (await res.json()) as T;
82
  }
83
 
84
- /** Vercel deploys `NEXT_PUBLIC_API_URL` as the HF Space URL. The HF
85
- * Space is CORS-open (`*`). */
86
- async function postForm<T>(path: string, form: FormData): Promise<T> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  const res = await fetch(`${API_URL}${path}`, {
88
  method: "POST",
89
  body: form,
 
90
  });
91
  if (!res.ok) {
92
  const detail = await res.text();
@@ -95,14 +161,70 @@ async function postForm<T>(path: string, form: FormData): Promise<T> {
95
  return (await res.json()) as T;
96
  }
97
 
98
- export async function inferWork(
99
- payload: { raw_text: string; date_from: string; date_to: string; axes?: string }
100
- ): Promise<InferredWorkResponse> {
 
 
 
 
101
  return postJSON<InferredWorkResponse>("/work/infer", payload);
102
  }
103
 
104
- export async function inferBlueprint(file: File): Promise<BlueprintInferenceResponse> {
 
 
 
 
105
  const form = new FormData();
106
  form.append("file", file);
107
- return postForm<BlueprintInferenceResponse>("/work/blueprint", form);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  }
 
68
  request_id: string;
69
  }
70
 
71
+ // ----- Projects -----
72
+
73
+ export interface ProjectPayload {
74
+ contract_no: string;
75
+ object_name: string;
76
+ client: string;
77
+ technical_supervisor: string;
78
+ contractor: string;
79
+ work_producer: string;
80
+ chief_engineer: string;
81
+ start_date?: string;
82
+ end_date_estimated?: string;
83
+ }
84
+
85
+ export interface Project extends ProjectPayload {
86
+ id: string;
87
+ created_at: string;
88
+ persisted?: boolean;
89
+ warning?: string;
90
+ }
91
+
92
+ // ----- Inference history -----
93
+
94
+ export interface InferenceHistoryRow {
95
+ id: string;
96
+ project_id: string | null;
97
+ request_id: string;
98
+ source_text: string | null;
99
+ payload: unknown;
100
+ model_used: string | null;
101
+ created_at: string;
102
+ }
103
+
104
+ // ----- Document templates -----
105
+
106
+ export interface DocumentContext {
107
+ [key: string]: unknown;
108
+ }
109
+
110
  async function postJSON<T>(path: string, body: unknown): Promise<T> {
111
  const res = await fetch(`${API_URL}${path}`, {
112
  method: "POST",
 
120
  return (await res.json()) as T;
121
  }
122
 
123
+ async function getJSON<T>(path: string): Promise<T> {
124
+ const res = await fetch(`${API_URL}${path}`, {
125
+ method: "GET",
126
+ headers: { Accept: "application/json" },
127
+ });
128
+ if (!res.ok) {
129
+ const detail = await res.text();
130
+ throw new Error(`API ${path} failed: ${res.status} ${detail}`);
131
+ }
132
+ return (await res.json()) as T;
133
+ }
134
+
135
+ async function delJSON<T>(path: string): Promise<T> {
136
+ const res = await fetch(`${API_URL}${path}`, {
137
+ method: "DELETE",
138
+ headers: { Accept: "application/json" },
139
+ });
140
+ if (!res.ok) {
141
+ const detail = await res.text();
142
+ throw new Error(`API ${path} failed: ${res.status} ${detail}`);
143
+ }
144
+ return (await res.json()) as T;
145
+ }
146
+
147
+ async function postForm<T>(
148
+ path: string,
149
+ form: FormData,
150
+ opts: { signal?: AbortSignal } = {}
151
+ ): Promise<T> {
152
  const res = await fetch(`${API_URL}${path}`, {
153
  method: "POST",
154
  body: form,
155
+ signal: opts.signal,
156
  });
157
  if (!res.ok) {
158
  const detail = await res.text();
 
161
  return (await res.json()) as T;
162
  }
163
 
164
+ export async function inferWork(payload: {
165
+ raw_text: string;
166
+ date_from: string;
167
+ date_to: string;
168
+ axes?: string;
169
+ project_id?: string | null;
170
+ }): Promise<InferredWorkResponse> {
171
  return postJSON<InferredWorkResponse>("/work/infer", payload);
172
  }
173
 
174
+ export async function inferBlueprint(
175
+ file: File,
176
+ project_id?: string | null,
177
+ opts: { signal?: AbortSignal } = {}
178
+ ): Promise<BlueprintInferenceResponse> {
179
  const form = new FormData();
180
  form.append("file", file);
181
+ if (project_id) form.append("project_id", project_id);
182
+ return postForm<BlueprintInferenceResponse>(
183
+ "/work/blueprint",
184
+ form,
185
+ opts
186
+ );
187
+ }
188
+
189
+ export async function createProject(payload: ProjectPayload): Promise<Project> {
190
+ return postJSON<Project>("/projects", payload);
191
+ }
192
+
193
+ export async function listProjects(): Promise<Project[]> {
194
+ return getJSON<Project[]>("/projects");
195
+ }
196
+
197
+ export async function getProject(project_id: string): Promise<Project> {
198
+ return getJSON<Project>(`/projects/${project_id}`);
199
+ }
200
+
201
+ export async function deleteProject(project_id: string): Promise<{ deleted: boolean; id: string }> {
202
+ return delJSON<{ deleted: boolean; id: string }>(`/projects/${project_id}`);
203
+ }
204
+
205
+ export async function listInferenceHistory(project_id?: string): Promise<InferenceHistoryRow[]> {
206
+ const q = project_id ? `?project_id=${encodeURIComponent(project_id)}` : "";
207
+ return getJSON<InferenceHistoryRow[]>(`/inference/history${q}`);
208
+ }
209
+
210
+ /** POST /documents/render — get back a .docx file as Blob. */
211
+ export async function renderDocument(
212
+ template_name: string,
213
+ project_id: string,
214
+ context: DocumentContext = {}
215
+ ): Promise<Blob> {
216
+ const res = await fetch(`${API_URL}/documents/render`, {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/json" },
219
+ body: JSON.stringify({ template_name, project_id, context }),
220
+ });
221
+ if (!res.ok) {
222
+ const detail = await res.text();
223
+ throw new Error(`API /documents/render failed: ${res.status} ${detail}`);
224
+ }
225
+ return res.blob();
226
+ }
227
+
228
+ export async function listDocumentTemplates(): Promise<{ supported: string[] }> {
229
+ return getJSON<{ supported: string[] }>("/documents/templates");
230
  }
requirements-dev.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # Virtual Foreman — Test-only dependencies
3
+ # Run `pip install -r requirements-dev.txt` before pytest.
4
+ # ============================================
5
+ -r requirements.txt
6
+ pytest==8.3.4
7
+ pytest-asyncio==0.25.0
tests/conftest.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pytest configuration shared across the suite.
2
+
3
+ Skips tests that require external services (Supabase, NVIDIA integrate)
4
+ unless the right env vars are set.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ import pytest
12
+
13
+
14
+ def _truthy(v: str | None) -> bool:
15
+ return v is not None and v not in ("", "0", "false", "False")
16
+
17
+
18
+ @pytest.fixture(scope="session")
19
+ def has_supabase() -> bool:
20
+ return _truthy(os.environ.get("SUPABASE_URL")) and _truthy(
21
+ os.environ.get("SUPABASE_SECRET_KEY")
22
+ or os.environ.get("SUPABASE_KEY")
23
+ )
24
+
25
+
26
+ @pytest.fixture(scope="session")
27
+ def has_nvidia() -> bool:
28
+ return _truthy(os.environ.get("NVIDIA_API_KEY")) or _truthy(
29
+ os.environ.get("LLM_API_KEY")
30
+ )
31
+
32
+
33
+ @pytest.fixture(scope="session")
34
+ def fastapi_client():
35
+ """Starlette TestClient for the FastAPI app.
36
+
37
+ Only constructed when no external network is needed (e.g. /health,
38
+ /, /documents/templates).
39
+ """
40
+ from fastapi.testclient import TestClient # type: ignore
41
+
42
+ # Provides query/key so supabase init doesn't blow up
43
+ os.environ.setdefault("SUPABASE_URL", "")
44
+ os.environ.setdefault("SUPABASE_SECRET_KEY", "")
45
+ os.environ.setdefault("NVIDIA_API_KEY", "test")
46
+
47
+ from app.main import app
48
+
49
+ return TestClient(app)
50
+
51
+
52
+ def pytest_collection_modifyitems(config, items):
53
+ """Mark tests that hit external services with a skip marker when
54
+ the required env var is missing.
55
+ """
56
+ skip_no_sb = pytest.mark.skip(reason="Supabase credentials required")
57
+ skip_no_nv = pytest.mark.skip(reason="NVIDIA API key required")
58
+ has_sb = _truthy(os.environ.get("SUPABASE_URL")) and _truthy(
59
+ os.environ.get("SUPABASE_SECRET_KEY")
60
+ or os.environ.get("SUPABASE_KEY")
61
+ )
62
+ has_nv = _truthy(os.environ.get("NVIDIA_API_KEY")) or _truthy(
63
+ os.environ.get("LLM_API_KEY")
64
+ )
65
+
66
+ for item in items:
67
+ if "supabase" in item.keywords and not has_sb:
68
+ item.add_marker(skip_no_sb)
69
+ if "nvidia" in item.keywords and not has_nv:
70
+ item.add_marker(skip_no_nv)
tests/test_api_smoke.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests for the FastAPI surface area.
2
+
3
+ Run:
4
+ pytest tests/ -v
5
+
6
+ External services stays behind affordances:
7
+ * `pytest -m "not supabase and not nvidia"` runs offline smoke only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+
14
+ import pytest
15
+
16
+
17
+ pytestmark = pytest.mark.asyncio
18
+
19
+
20
+ # ---- /health & / ----
21
+
22
+ def test_health(fastapi_client):
23
+ r = fastapi_client.get("/health")
24
+ assert r.status_code == 200, r.text
25
+ body = r.json()
26
+ assert body["status"] == "ok"
27
+ assert body["service"] == "virtual-foreman-backend"
28
+
29
+
30
+ def test_root_lists_endpoints(fastapi_client):
31
+ r = fastapi_client.get("/")
32
+ assert r.status_code == 200
33
+ body = r.json()
34
+ assert "POST /projects" in body["endpoints"]
35
+ assert "POST /work/infer" in body["endpoints"]
36
+ assert "POST /work/blueprint" in body["endpoints"]
37
+ assert "POST /documents/render" in body["endpoints"]
38
+ assert "GET /inference/history" in body["endpoints"]
39
+
40
+
41
+ def test_request_id_header_echoed(fastapi_client):
42
+ r = fastapi_client.get("/health", headers={"X-Request-ID": "rid-abc-123"})
43
+ assert r.headers.get("X-Request-ID") == "rid-abc-123"
44
+
45
+
46
+ def test_request_id_autogenerated_when_missing(fastapi_client):
47
+ r = fastapi_client.get("/health")
48
+ rid = r.headers.get("X-Request-ID")
49
+ assert rid is not None and len(rid) > 0
50
+
51
+
52
+ # ---- /documents/templates ----
53
+
54
+ def test_documents_templates(fastapi_client):
55
+ r = fastapi_client.get("/documents/templates")
56
+ assert r.status_code == 200
57
+ body = r.json()
58
+ assert "supported" in body
59
+ assert isinstance(body["supported"], list)
60
+ assert "act_hidden_works" in body["supported"]
61
+ assert "general_work_log" in body["supported"]
62
+
63
+
64
+ # ---- /work/infer ----
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_work_infer_no_payload_returns_422(fastapi_client):
68
+ r = fastapi_client.post("/work/infer", json={"wrong": "shape"})
69
+ assert r.status_code == 422
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_work_infer_short_text_returns_422(fastapi_client):
74
+ r = fastapi_client.post(
75
+ "/work/infer",
76
+ json={
77
+ "raw_text": "ab", # too short
78
+ "date_from": "2026-06-22",
79
+ "date_to": "2026-06-22",
80
+ },
81
+ )
82
+ assert r.status_code == 422
83
+
84
+
85
+ # ---- /work/blueprint (offline-friendly path) ----
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_blueprint_reject_invalid_mime(fastapi_client):
89
+ fake = (b"not really a video", "test.gif", "image/gif")
90
+ r = fastapi_client.post(
91
+ "/work/blueprint",
92
+ files={"file": (fake[1], io.BytesIO(fake[0]), fake[2])},
93
+ )
94
+ assert r.status_code == 415
95
+ assert "image/png" in r.json()["detail"]
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_blueprint_reject_empty(fastapi_client):
100
+ r = fastapi_client.post(
101
+ "/work/blueprint",
102
+ files={"file": ("empty.png", io.BytesIO(b""), "image/png")},
103
+ )
104
+ # FastAPI's UploadFile may read empty bytes — endpoint catches and
105
+ # returns 400 "empty upload".
106
+ assert r.status_code in (400, 422)
107
+ detail = (r.json().get("detail") or "").lower()
108
+ assert "empty" in detail or "required" in detail
109
+
110
+
111
+ # ---- /projects validation ----
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_projects_create_missing_fields_returns_422(fastapi_client):
115
+ r = fastapi_client.post("/projects", json={"contract_no": "12-2026/BN"})
116
+ # contract_no isn't enough — many required fields missing.
117
+ assert r.status_code == 422
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_projects_get_unknown_id_returns_404_or_503(fastapi_client):
122
+ """Off the offline path this either 404s (real Supabase), 503 (Supabase
123
+ unreachable), 500 (broken client) — never 200 because the row isn't
124
+ real.
125
+ """
126
+ r = fastapi_client.get(
127
+ "/projects/00000000-0000-0000-0000-000000000000"
128
+ )
129
+ assert r.status_code in (404, 503)
tests/test_schemas.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema-validation tests (Pure-Python, no HF/SUPA/network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+
8
+ from app.models.blueprint import (
9
+ BlueprintExtractionPayload,
10
+ BlueprintSheet,
11
+ BlueprintDimensions,
12
+ GridAxis,
13
+ )
14
+ from app.models.tech_stack import (
15
+ DailyWorkInput,
16
+ TechStack,
17
+ TechStep,
18
+ )
19
+
20
+
21
+ def test_daily_work_input_basic_success():
22
+ payload = DailyWorkInput(
23
+ raw_text="Залита плита перекрытия в осях А-В/1-3",
24
+ date_from="2026-06-22",
25
+ date_to="2026-06-22",
26
+ axes="А-В / 1-3",
27
+ project_id="deadbeef-dead-beef-dead-beefdeadbeef",
28
+ )
29
+ assert payload.axes == "А-В / 1-3"
30
+ assert len(payload.project_id) == 36
31
+
32
+
33
+ def test_daily_work_input_short_text_rejected():
34
+ with pytest.raises(ValidationError):
35
+ DailyWorkInput(
36
+ raw_text="ok",
37
+ date_from="2026-06-22",
38
+ date_to="2026-06-22",
39
+ )
40
+
41
+
42
+ def test_daily_work_input_long_text_rejected():
43
+ with pytest.raises(ValidationError):
44
+ DailyWorkInput(
45
+ raw_text="x" * 4001,
46
+ date_from="2026-06-22",
47
+ date_to="2026-06-22",
48
+ )
49
+
50
+
51
+ def test_techstack_carries_envelope():
52
+ step = TechStep(
53
+ order=1,
54
+ title="Подготовка основания",
55
+ description="Снятие плодородного слоя, device-каток 1.4 т.",
56
+ required_acts=["АОСР-1"],
57
+ dependencies=[],
58
+ weather_sensitive=True,
59
+ )
60
+ ts = TechStack(
61
+ source_input="—",
62
+ model_used="deepseek-v4-flash",
63
+ generated_at="2026-06-22T20:30:00+00:00",
64
+ inferred_summary="Этапы",
65
+ steps=[step],
66
+ required_documents=[],
67
+ missing_info_prompts=[],
68
+ )
69
+ assert ts.steps[0].order == 1
70
+ assert ts.steps[0].weather_sensitive is True
71
+
72
+
73
+ def test_blueprint_extraction_requires_at_least_one_sheet():
74
+ with pytest.raises(ValidationError):
75
+ BlueprintExtractionPayload(
76
+ project_hint="hdr",
77
+ sheets=[],
78
+ )
79
+
80
+
81
+ def test_blueprint_extraction_happy():
82
+ payload = BlueprintExtractionPayload(
83
+ project_hint="Жилой дом, корп. 2",
84
+ sheets=[
85
+ BlueprintSheet(
86
+ sheet_number="АР-1",
87
+ title="План 1 этажа",
88
+ scale="1:100",
89
+ dimensions=BlueprintDimensions(
90
+ length_m=44.0,
91
+ width_m=12.0,
92
+ height_m=3.0,
93
+ total_area_m2=528.0,
94
+ ),
95
+ room_labels=["Кухня", "Гостиная"],
96
+ grid_axes=[
97
+ GridAxis(label="А", order=1),
98
+ GridAxis(label="1", order=2),
99
+ ],
100
+ notes_ru=["Высота потолка 3.0"],
101
+ )
102
+ ],
103
+ inferred_disciplines=["АР"],
104
+ missing_info_prompts=[],
105
+ )
106
+ assert payload.sheets[0].dimensions.length_m == 44.0
tests/test_supabase_mapping.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mapping unit tests for Supabase column bridges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.services.supabase_service import project_from_supabase, project_to_supabase
6
+
7
+
8
+ def test_roundtrip_preserves_canonical_fields():
9
+ canonical = {
10
+ "id": "uuid-1",
11
+ "object_name": "Жилой дом № 5",
12
+ "contract_no": "12-2026/БН",
13
+ "client": "ООО «Заказчик»",
14
+ "technical_supervisor": "Иванов И.И.",
15
+ "contractor": "ООО «Подряд»",
16
+ "work_producer": "Петров П.П.",
17
+ "chief_engineer": "Сидоров С.С.",
18
+ "created_at": "2026-06-22T10:00:00+00:00",
19
+ }
20
+ row = project_to_supabase(canonical)
21
+ assert row["name"] == "Жилой дом № 5"
22
+ assert row["contract_number"] == "12-2026/БН"
23
+ roundtripped = project_from_supabase({
24
+ "id": "uuid-1",
25
+ "name": row["name"],
26
+ "contract_number": row["contract_number"],
27
+ "client": row["client"],
28
+ "tech_supervisor": row["tech_supervisor"],
29
+ "contractor": row["contractor"],
30
+ "responsible_foreman": row["responsible_foreman"],
31
+ "chief_engineer": row["chief_engineer"],
32
+ "created_at": row["created_at"],
33
+ })
34
+ assert roundtripped["id"] == "uuid-1"
35
+ assert roundtripped["object_name"] == "Жилой дом № 5"
36
+ assert roundtripped["work_producer"] == "Петров П.П."
37
+ # canonical fields not in pre-existing schema are dropped naturally
38
+ assert "start_date" not in roundtripped