Импорт .docx удалён: не использовался (0 кейсов), парсер не совместим с русскими спецдонесениями, LLM-распознавание вырезано
- admin.py: /parse-doc + _extract_docx_text/_extract_kv_pairs/_coerce_preview/ _parse_bool удалены; импорты почищены. - schemas.py: ParseDocResponse удалена. - AdminDashboard: секция «Импорт отчёта .docx» + все upload/parse handlers и state удалены (компонент стал на ~160 строк короче).
This commit is contained in:
+2
-101
@@ -1,107 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
import re
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from backend.database import db
|
from backend.database import db
|
||||||
from backend.routers.auth import require_roles
|
from backend.routers.auth import require_roles
|
||||||
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse
|
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse
|
||||||
|
|
||||||
log = logging.getLogger('vector.admin')
|
log = logging.getLogger('vector.admin')
|
||||||
|
|
||||||
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
|
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(value: str | None) -> bool | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if normalized in {'1', 'true', 'yes', 'y', 'да'}:
|
|
||||||
return True
|
|
||||||
if normalized in {'0', 'false', 'no', 'n', 'нет'}:
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_docx_text(content: bytes) -> str:
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(BytesIO(content)) as archive:
|
|
||||||
xml = archive.read('word/document.xml')
|
|
||||||
root = ET.fromstring(xml)
|
|
||||||
except (zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
|
|
||||||
raise ValueError('Не удалось прочитать .docx: файл повреждён или имеет неверный формат') from exc
|
|
||||||
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
|
|
||||||
paragraphs: list[str] = []
|
|
||||||
for paragraph in root.findall('.//w:body/w:p', ns):
|
|
||||||
texts = [node.text for node in paragraph.findall('.//w:t', ns) if node.text]
|
|
||||||
if texts:
|
|
||||||
paragraphs.append(''.join(texts).strip())
|
|
||||||
return '\n'.join(paragraphs).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_kv_pairs(raw_text: str) -> dict[str, str]:
|
|
||||||
parsed: dict[str, str] = {}
|
|
||||||
for line in raw_text.splitlines():
|
|
||||||
if ':' not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split(':', 1)
|
|
||||||
parsed[key.strip().lower()] = value.strip()
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_preview(raw_text: str) -> dict:
|
|
||||||
pairs = _extract_kv_pairs(raw_text)
|
|
||||||
case: dict[str, object] = {}
|
|
||||||
|
|
||||||
if 'age' in pairs:
|
|
||||||
try:
|
|
||||||
case['age'] = int(pairs['age'])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if 'gender' in pairs:
|
|
||||||
case['gender'] = pairs['gender']
|
|
||||||
if 'status' in pairs:
|
|
||||||
case['status'] = pairs['status']
|
|
||||||
if 'found alive' in pairs:
|
|
||||||
case['found_alive'] = _parse_bool(pairs['found alive'])
|
|
||||||
if 'found distance km' in pairs:
|
|
||||||
try:
|
|
||||||
case['found_distance_km'] = float(pairs['found distance km'])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if 'found lat' in pairs:
|
|
||||||
try:
|
|
||||||
case['found_lat'] = float(pairs['found lat'])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if 'found lon' in pairs:
|
|
||||||
try:
|
|
||||||
case['found_lon'] = float(pairs['found lon'])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if 'who found' in pairs:
|
|
||||||
case['who_found'] = pairs['who found']
|
|
||||||
if 'notes' in pairs:
|
|
||||||
case['notes'] = pairs['notes']
|
|
||||||
elif 'note' in pairs:
|
|
||||||
case['notes'] = pairs['note']
|
|
||||||
|
|
||||||
case_id = None
|
|
||||||
if 'case id' in pairs:
|
|
||||||
match = re.search(r'\d+', pairs['case id'])
|
|
||||||
if match:
|
|
||||||
case_id = match.group(0)
|
|
||||||
|
|
||||||
return {'raw_text': raw_text, 'case_id': case_id, 'case': case}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/cases', response_model=CaseListResponse)
|
@router.get('/cases', response_model=CaseListResponse)
|
||||||
def admin_cases(
|
def admin_cases(
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
@@ -157,14 +69,3 @@ def admin_update_case(case_id: str, payload: CaseUpdate) -> dict:
|
|||||||
@router.get('/dashboard', response_model=DashboardResponse)
|
@router.get('/dashboard', response_model=DashboardResponse)
|
||||||
def admin_dashboard() -> dict:
|
def admin_dashboard() -> dict:
|
||||||
return db.stats()
|
return db.stats()
|
||||||
|
|
||||||
|
|
||||||
@router.post('/parse-doc', response_model=ParseDocResponse)
|
|
||||||
async def admin_parse_doc(file: UploadFile = File(...)) -> dict:
|
|
||||||
content = await file.read()
|
|
||||||
try:
|
|
||||||
raw_text = _extract_docx_text(content)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
preview = _coerce_preview(raw_text)
|
|
||||||
return {'filename': file.filename, 'parsed': True, 'preview': preview, 'raw_text': raw_text}
|
|
||||||
|
|||||||
@@ -135,13 +135,6 @@ class AnalyzeResponse(BaseModel):
|
|||||||
case_id: str
|
case_id: str
|
||||||
|
|
||||||
|
|
||||||
class ParseDocResponse(BaseModel):
|
|
||||||
filename: str
|
|
||||||
parsed: bool
|
|
||||||
preview: dict[str, Any]
|
|
||||||
raw_text: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ================= ЗАВЕРШЁННЫЕ ПОИСКИ (ручной ввод, без ИИ) =================
|
# ================= ЗАВЕРШЁННЫЕ ПОИСКИ (ручной ввод, без ИИ) =================
|
||||||
# Оператор руками вносит данные ЗАВЕРШЁННОГО поиска: параметры субъекта/среды
|
# Оператор руками вносит данные ЗАВЕРШЁННОГО поиска: параметры субъекта/среды
|
||||||
# (вход матмодели) + реальный исход. Цель — датасет для калибровки модели
|
# (вход матмодели) + реальный исход. Цель — датасет для калибровки модели
|
||||||
|
|||||||
@@ -148,14 +148,6 @@ function AdminDashboard() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [saveError, setSaveError] = useState('');
|
||||||
const [saveMessage, setSaveMessage] = useState('');
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
const [uploadFile, setUploadFile] = useState(null);
|
|
||||||
const [uploadLoading, setUploadLoading] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState('');
|
|
||||||
const [uploadMessage, setUploadMessage] = useState('');
|
|
||||||
const [parsePreview, setParsePreview] = useState(null);
|
|
||||||
const [parseRawText, setParseRawText] = useState('');
|
|
||||||
const [parseCaseId, setParseCaseId] = useState('');
|
|
||||||
const [parseCaseFields, setParseCaseFields] = useState({});
|
|
||||||
|
|
||||||
const selectedCase = useMemo(
|
const selectedCase = useMemo(
|
||||||
() => cases.find((item) => item.id === selectedCaseId) || null,
|
() => cases.find((item) => item.id === selectedCaseId) || null,
|
||||||
@@ -240,116 +232,6 @@ function AdminDashboard() {
|
|||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
const parsePreviewFields = useMemo(() => {
|
|
||||||
if (!parsePreview) return [];
|
|
||||||
return Object.entries(parsePreview).filter(([key]) => key !== 'case' && key !== 'raw_text');
|
|
||||||
}, [parsePreview]);
|
|
||||||
|
|
||||||
function resetParseState() {
|
|
||||||
setUploadFile(null);
|
|
||||||
setUploadError('');
|
|
||||||
setUploadMessage('');
|
|
||||||
setParsePreview(null);
|
|
||||||
setParseRawText('');
|
|
||||||
setParseCaseId('');
|
|
||||||
setParseCaseFields({});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUploadFile(event) {
|
|
||||||
setUploadFile(event.target.files?.[0] || null);
|
|
||||||
setUploadError('');
|
|
||||||
setUploadMessage('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUploadPreview(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!uploadFile) {
|
|
||||||
setUploadError('Выбери .docx файл для предпросмотра');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadLoading(true);
|
|
||||||
setUploadError('');
|
|
||||||
setUploadMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', uploadFile);
|
|
||||||
|
|
||||||
const response = await apiFetch('/api/v1/admin/parse-doc', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(body.detail || `Не удалось распарсить документ (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
setParsePreview(payload.preview || {});
|
|
||||||
setParseRawText(payload.raw_text || '');
|
|
||||||
setParseCaseId(payload.preview?.case_id || '');
|
|
||||||
setParseCaseFields(payload.preview?.case || {});
|
|
||||||
setUploadMessage('Документ распознан, можно проверить preview ниже');
|
|
||||||
} catch (err) {
|
|
||||||
setUploadError(err.message || 'Ошибка загрузки файла');
|
|
||||||
} finally {
|
|
||||||
setUploadLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldFromPreview(name, fallback = '') {
|
|
||||||
const value = parseCaseFields?.[name];
|
|
||||||
return value === undefined || value === null || value === '' ? fallback : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImportPreview(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!parseCaseFields || Object.keys(parseCaseFields).length === 0) {
|
|
||||||
setUploadError('Сначала загрузи и распарсь .docx файл');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadLoading(true);
|
|
||||||
setUploadError('');
|
|
||||||
setUploadMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
age: parseCaseFields.age ?? null,
|
|
||||||
gender: parseCaseFields.gender ?? null,
|
|
||||||
status: parseCaseFields.status ?? 'new',
|
|
||||||
found_alive: parseCaseFields.found_alive ?? null,
|
|
||||||
found_distance_km: parseCaseFields.found_distance_km ?? null,
|
|
||||||
found_lat: parseCaseFields.found_lat ?? null,
|
|
||||||
found_lon: parseCaseFields.found_lon ?? null,
|
|
||||||
who_found: parseCaseFields.who_found ?? null,
|
|
||||||
note: parseCaseFields.notes ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await apiFetch('/api/v1/cases', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(body.detail || `Не удалось создать кейс (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await response.json();
|
|
||||||
setUploadMessage(`Кейс создан: ${created.id}`);
|
|
||||||
resetParseState();
|
|
||||||
setSaveMessage('Импорт из .docx завершён');
|
|
||||||
setPage(1);
|
|
||||||
} catch (err) {
|
|
||||||
setUploadError(err.message || 'Ошибка импорта');
|
|
||||||
} finally {
|
|
||||||
setUploadLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFilterChange(event) {
|
function handleFilterChange(event) {
|
||||||
const { name, value } = event.target;
|
const { name, value } = event.target;
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -560,71 +442,6 @@ function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-panel admin-upload-panel">
|
|
||||||
<div className="admin-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>Импорт отчёта .docx</h2>
|
|
||||||
<p>Загрузите документ, проверьте preview и импортируйте извлечённые поля в новый кейс.</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-upload-hint">
|
|
||||||
{parseCaseId ? `Preview case_id: ${parseCaseId}` : 'Ждём файл'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="admin-upload-form" onSubmit={handleUploadPreview}>
|
|
||||||
<div className="admin-upload-row">
|
|
||||||
<label className="admin-file-picker">
|
|
||||||
<span>Выбери .docx</span>
|
|
||||||
<input type="file" accept=".docx" onChange={handleUploadFile} />
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="admin-secondary-button" disabled={uploadLoading}>
|
|
||||||
{uploadLoading ? 'Распознаю…' : 'Распознать'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="admin-primary-button" onClick={handleImportPreview} disabled={uploadLoading}>
|
|
||||||
Импортировать в кейс
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{uploadError ? <div className="admin-alert admin-alert-error">{uploadError}</div> : null}
|
|
||||||
{uploadMessage ? <div className="admin-alert admin-alert-success">{uploadMessage}</div> : null}
|
|
||||||
|
|
||||||
{parsePreview ? (
|
|
||||||
<div className="admin-upload-preview">
|
|
||||||
<div className="admin-upload-grid">
|
|
||||||
<div>
|
|
||||||
<h3>Распознанные поля</h3>
|
|
||||||
<ul className="admin-preview-list">
|
|
||||||
{parsePreviewFields.map(([key, value]) => (
|
|
||||||
<li key={key}>
|
|
||||||
<strong>{key}</strong>
|
|
||||||
<span>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Кейс preview</h3>
|
|
||||||
<ul className="admin-preview-list">
|
|
||||||
<li><strong>age</strong><span>{fieldFromPreview('age', '—')}</span></li>
|
|
||||||
<li><strong>gender</strong><span>{fieldFromPreview('gender', '—')}</span></li>
|
|
||||||
<li><strong>status</strong><span>{fieldFromPreview('status', 'new')}</span></li>
|
|
||||||
<li><strong>found_alive</strong><span>{fieldFromPreview('found_alive', '—')}</span></li>
|
|
||||||
<li><strong>found_distance_km</strong><span>{fieldFromPreview('found_distance_km', '—')}</span></li>
|
|
||||||
<li><strong>who_found</strong><span>{fieldFromPreview('who_found', '—')}</span></li>
|
|
||||||
<li><strong>notes</strong><span>{fieldFromPreview('notes', '—')}</span></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{parseRawText ? (
|
|
||||||
<details className="admin-raw-details">
|
|
||||||
<summary>Показать raw text</summary>
|
|
||||||
<pre className="admin-raw-text">{parseRawText}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{error ? <div className="admin-alert admin-alert-error">{error}</div> : null}
|
{error ? <div className="admin-alert admin-alert-error">{error}</div> : null}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user