Spaces:
Running
chore: pre-release — full project lifecycle (CRUD, history, docs, tests)
Browse filesBackend:
- 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 +87 -18
- RELEASING.md +93 -0
- app/blueprint/page.tsx +21 -1
- app/documents/page.tsx +203 -31
- app/history/page.tsx +169 -0
- app/main.py +49 -10
- app/models/tech_stack.py +7 -0
- app/page.tsx +12 -0
- app/project/new/page.tsx +130 -29
- app/project/page.tsx +105 -0
- app/routers/__init__.py +7 -1
- app/routers/inference.py +23 -0
- app/routers/projects.py +51 -15
- app/routers/work.py +22 -6
- app/services/supabase_service.py +62 -3
- app/work/page.tsx +31 -1
- lib/api.ts +130 -8
- requirements-dev.txt +7 -0
- tests/conftest.py +70 -0
- tests/test_api_smoke.py +129 -0
- tests/test_schemas.py +106 -0
- tests/test_supabase_mapping.py +38 -0
|
@@ -1,18 +1,87 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
- `/
|
| 18 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -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`.
|
|
@@ -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">
|
|
@@ -1,43 +1,215 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 16 |
-
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
</
|
| 22 |
-
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
<
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
>
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"POST
|
| 85 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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):
|
|
@@ -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">
|
|
@@ -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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
technical_supervisor: "",
|
| 11 |
-
contractor: "",
|
| 12 |
-
work_producer: "",
|
| 13 |
-
chief_engineer: "",
|
| 14 |
-
});
|
| 15 |
-
const [submitted, setSubmitted] = useState(false);
|
| 16 |
|
| 17 |
-
const handle =
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
const submit = async (e: React.FormEvent) => {
|
| 21 |
e.preventDefault();
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
};
|
| 26 |
|
| 27 |
-
const fields: {
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
{ key: "client", label: "Заказчик" },
|
| 31 |
{ key: "technical_supervisor", label: "Технадзор" },
|
| 32 |
{ key: "contractor", label: "Подрядчик" },
|
| 33 |
-
{
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 42 |
-
<div className="
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 54 |
<input
|
| 55 |
required
|
| 56 |
value={form[f.key]}
|
|
@@ -62,12 +144,31 @@ export default function NewProjectPage() {
|
|
| 62 |
))}
|
| 63 |
<button
|
| 64 |
type="submit"
|
| 65 |
-
|
|
|
|
| 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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
]
|
|
@@ -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 |
+
)
|
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
"""Project CRUD router (
|
| 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=
|
| 17 |
-
async def create_project(payload: ProjectCreate) ->
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
-
@router.get("
|
| 29 |
-
async def
|
|
|
|
|
|
|
| 30 |
sb = get_supabase_service()
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
@@ -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=
|
| 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(
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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"),
|
|
@@ -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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
*,
|
|
@@ -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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
@@ -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 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return postJSON<InferredWorkResponse>("/work/infer", payload);
|
| 102 |
}
|
| 103 |
|
| 104 |
-
export async function inferBlueprint(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
const form = new FormData();
|
| 106 |
form.append("file", file);
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|