Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -13,7 +13,7 @@ from common_util.common_util_auth import verify_session
|
||||
|
||||
router = APIRouter(prefix="/knowledge-chat", tags=["B01_KnowledgeChat"])
|
||||
KNOWLEDGE = Path(__file__).resolve().parents[1] / "resources" / "knowledge"
|
||||
EXCLUDED = ("실무문서", "stmate", "다산소프트")
|
||||
EXCLUDED = ("실무문서", "stmate", "다산소프트", "technical_info")
|
||||
MODEL = "gemini-3.8-flash"
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -547,10 +548,23 @@ async def get_user_ui_prefs(user_id: int) -> dict[str, Any]:
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
_UUID_IN_KEY = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
||||
|
||||
|
||||
async def save_user_ui_prefs(user_id: int, prefs: dict[str, Any]) -> None:
|
||||
"""화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄."""
|
||||
"""화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄.
|
||||
|
||||
지워진 프로젝트 id 가 박힌 키는 버림 — 브라우저 사본이 옛 키를 다시 올리지 못하게.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
keyed = {key: m.group(0) for key in prefs if (m := _UUID_IN_KEY.search(key))}
|
||||
if keyed:
|
||||
ids = sorted(set(keyed.values()))
|
||||
holders = ", ".join(["%s"] * len(ids))
|
||||
await cursor.execute(f"SELECT id FROM projects WHERE id IN ({holders})", ids)
|
||||
live = {row[0] for row in await cursor.fetchall()}
|
||||
prefs = {k: v for k, v in prefs.items() if k not in keyed or keyed[k] in live}
|
||||
await cursor.execute(
|
||||
"""INSERT INTO user_ui_prefs (user_id, prefs) VALUES (%s, %s)
|
||||
ON DUPLICATE KEY UPDATE prefs = VALUES(prefs)""",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
type Source = { path: string; excerpt: string };
|
||||
type ChatAnswer = { answer: string; sources: Source[]; detail?: string };
|
||||
const chatIcon = `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5h16a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H13l-7 4v-5a5 5 0 0 1-3-4V10a5 5 0 0 1 5-5Z"/><path d="M11 19c0-5 4-8 10-8 0 6-3 10-8 10"/><path d="M11 22c2-3 5-5 9-7"/></svg>`;
|
||||
|
||||
export function buildKnowledgeChat(): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -9,25 +10,44 @@ export function buildKnowledgeChat(): HTMLElement {
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "b01-knowledge-chat__toggle";
|
||||
toggle.textContent = "원문 안내";
|
||||
toggle.innerHTML = chatIcon;
|
||||
toggle.setAttribute("aria-label", "챗봇 열기");
|
||||
toggle.title = "챗봇";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "b01-knowledge-chat__panel";
|
||||
panel.hidden = true;
|
||||
panel.setAttribute("aria-label", "지식DB 원문 안내 채팅");
|
||||
panel.id = "b01-knowledge-chat-panel";
|
||||
panel.setAttribute("aria-label", "챗봇");
|
||||
toggle.setAttribute("aria-controls", panel.id);
|
||||
const header = document.createElement("header");
|
||||
header.textContent = "지식DB 원문 안내";
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "b01-knowledge-chat__avatar";
|
||||
avatar.innerHTML = chatIcon;
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "챗봇";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "b01-knowledge-chat__close";
|
||||
close.textContent = "×";
|
||||
close.setAttribute("aria-label", "챗봇 닫기");
|
||||
header.append(avatar, title, close);
|
||||
const messages = document.createElement("div");
|
||||
messages.className = "b01-knowledge-chat__messages";
|
||||
messages.setAttribute("role", "log");
|
||||
messages.setAttribute("aria-live", "polite");
|
||||
const welcome = document.createElement("p");
|
||||
welcome.className = "b01-knowledge-chat__reply";
|
||||
welcome.textContent =
|
||||
"안녕하세요! 법령이나 지침에 대해 질문해 주세요. 근거 문서도 함께 보여드릴게요.";
|
||||
messages.append(welcome);
|
||||
const form = document.createElement("form");
|
||||
form.className = "b01-knowledge-chat__form";
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.required = true;
|
||||
input.maxLength = 500;
|
||||
input.placeholder = "법령·지침을 질문하세요";
|
||||
input.placeholder = "메시지를 입력하세요";
|
||||
input.setAttribute("aria-label", "질문");
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
@@ -36,11 +56,15 @@ export function buildKnowledgeChat(): HTMLElement {
|
||||
panel.append(header, messages, form);
|
||||
wrap.append(toggle, panel);
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
const setOpen = (open: boolean): void => {
|
||||
panel.hidden = !open;
|
||||
toggle.setAttribute("aria-expanded", String(!panel.hidden));
|
||||
if (!panel.hidden) input.focus();
|
||||
});
|
||||
toggle.setAttribute("aria-label", open ? "챗봇 닫기" : "챗봇 열기");
|
||||
if (open) input.focus();
|
||||
else toggle.focus();
|
||||
};
|
||||
toggle.addEventListener("click", () => setOpen(Boolean(panel.hidden)));
|
||||
close.addEventListener("click", () => setOpen(false));
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -535,16 +535,30 @@
|
||||
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(540px, calc(100vh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -560,34 +574,76 @@
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__messages {
|
||||
height: calc(100% - 106px);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user,
|
||||
.b01-knowledge-chat__reply {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
max-width: 88%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply {
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-background, #f4f4f4);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply summary {
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -602,6 +658,7 @@
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -615,3 +672,14 @@
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat button:focus-visible,
|
||||
.b01-knowledge-chat input:focus-visible {
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* B05_Profile_UI_Structures_Balloon.ts
|
||||
* 종단 구조물 알약 정보 라벨 줄(54-14) — 화면 요소 없는 순수 규칙. B05 · B06 알약 레인이 같이 씀.
|
||||
*
|
||||
* · 줄 = 종류 이름(기타 = 손 이름) · 기준 측점(구간 = 시작 ~ 끝) · 컨테이너마다 첫 옵션 값 한 줄
|
||||
* (「관: 관경 1000」) · 컨테이너가 없으면 앞 두 줄만 · 메모 · 그 밖 옵션은 안 씀
|
||||
* · 2행 — 1행 = 종류 이름(기타 = 손 이름) + 기준 측점(구간 = 시작 ~ 끝) · 2행 = 컨테이너마다 첫 옵션
|
||||
* 값만 「 · 」 로 이음(제목 · 옵션 이름 없음 · 빈 값은 건너뜀) · 값이 하나도 없으면 1행만 · 메모 · 그 밖 옵션은 안 씀
|
||||
* · 컨테이너 = 좌측 패널 폼과 같은 상자 — 명세 종류(`STRUCTURE_SPECS`)는 명세 컨테이너(선택 컨테이너는
|
||||
* 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`)
|
||||
* · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀)
|
||||
* · 값 `auto` = 「(자동)」 · 빈 값 = 「—」(54-39 · 기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값)
|
||||
* · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
branchOf,
|
||||
choiceLabel,
|
||||
choicesOf,
|
||||
containerTitle,
|
||||
itemLabel,
|
||||
readState,
|
||||
type Instance,
|
||||
type SpecContainer,
|
||||
@@ -28,12 +26,12 @@ import {
|
||||
} from "../A00_Common/design/design_structure_spec";
|
||||
|
||||
const AUTO_MARK = "(자동)";
|
||||
const NONE = "—";
|
||||
const SEP = " · ";
|
||||
|
||||
/** 선택 컨테이너 개수를 밖에서 정함(배관 흙막이 = 유출 단 수 · 폼 `hooks.count` 와 같은 값). */
|
||||
export type ContainerCount = (containerId: string) => number | null | undefined;
|
||||
|
||||
/** 라벨 줄 — [이름, 기준 측점, ...컨테이너 줄]. */
|
||||
/** 라벨 줄 — [이름 기준 측점, 컨테이너 값 이음?]. */
|
||||
export function balloonLines(
|
||||
structure: StructureInstance,
|
||||
type: StructureType | undefined,
|
||||
@@ -41,13 +39,14 @@ export function balloonLines(
|
||||
count?: ContainerCount,
|
||||
): string[] {
|
||||
const spec = STRUCTURE_SPECS[structure.type_id];
|
||||
const rest = spec
|
||||
? specLines(spec, structure, type, count)
|
||||
: groupLines(structure.options ?? {}, type);
|
||||
return [head.name, head.position, ...rest];
|
||||
const values = (
|
||||
spec ? specLines(spec, structure, type, count) : groupLines(structure.options ?? {}, type)
|
||||
).filter(Boolean);
|
||||
const first = `${head.name} ${head.position}`;
|
||||
return values.length ? [first, values.join(SEP)] : [first];
|
||||
}
|
||||
|
||||
/** 명세 컨테이너 줄 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름. */
|
||||
/** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */
|
||||
function specLines(
|
||||
spec: StructureSpec,
|
||||
structure: StructureInstance,
|
||||
@@ -61,19 +60,14 @@ function specLines(
|
||||
names = pipeNames;
|
||||
}
|
||||
const state = formState(spec, structure.type_id, structure.options ?? {});
|
||||
const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
return spec.containers.flatMap((c) => {
|
||||
const title = containerTitle(c, names, groups[c.link]);
|
||||
return instancesOf(c, state[c.id] ?? [], count).map((values, index) => {
|
||||
return spec.containers.flatMap((c) =>
|
||||
instancesOf(c, state[c.id] ?? [], count).map((values) => {
|
||||
const item = c.items.find(
|
||||
(entry) => entry.kind !== "auto" && (!entry.by || branchOf(entry, values)),
|
||||
);
|
||||
const head = c.required ? title : `${title} ${index + 1}`;
|
||||
return item
|
||||
? `${head}: ${itemLabel(c, item, names)} ${specValue(c, item, values, names)}`
|
||||
: `${head}: ${NONE}`;
|
||||
});
|
||||
});
|
||||
return item ? specValue(c, item, values, names) : "";
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 저장 값 → 폼 칸 값 — 폼(`Drainage_Facility.setFacility`)과 같은 손질(BOX 옛 설치 · 본체 규격 ·
|
||||
@@ -116,10 +110,10 @@ function specValue(
|
||||
};
|
||||
const raw = (values[item.id] ?? "").trim();
|
||||
if (raw === "auto") return AUTO_MARK;
|
||||
return raw ? shown(raw) : NONE;
|
||||
return raw ? shown(raw) : "";
|
||||
}
|
||||
|
||||
/** 정의 옵션 그룹 줄 — 그룹마다 첫 옵션(정의에 있고 켜진 칸). */
|
||||
/** 정의 옵션 그룹 값 — 그룹마다 첫 옵션(정의에 있고 켜진 칸 · 빈 값 = ""). */
|
||||
function groupLines(
|
||||
options: Record<string, string | number>,
|
||||
type: StructureType | undefined,
|
||||
@@ -129,11 +123,9 @@ function groupLines(
|
||||
const option = group.options
|
||||
.map((key) => byKey.get(key))
|
||||
.find((entry) => entry && entry.visible !== false && entry.enabled !== false);
|
||||
if (!option) return `${group.name}: ${NONE}`;
|
||||
const unit = option.unit ?? "";
|
||||
if (!option) return "";
|
||||
const raw = options[option.key];
|
||||
const value =
|
||||
raw === "auto" ? AUTO_MARK : raw !== undefined && raw !== "" ? `${raw}${unit}` : NONE;
|
||||
return `${group.name}: ${option.label} ${value}`;
|
||||
if (raw === "auto") return AUTO_MARK;
|
||||
return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : "";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
|
||||
* 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18
|
||||
* 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로
|
||||
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다.
|
||||
* 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지
|
||||
* 줄 = 2행 — 이름 + 기준 측점 · 컨테이너마다 첫 옵션 값 이음(`balloonLines` · 54-14 · 54-46) · 그래프 바닥 ▼ 단추를 가리지
|
||||
* 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */
|
||||
function buildBalloon(
|
||||
lines: string[],
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""검증 프로젝트 일괄 새로 만들기 — 같은 입력 원본으로 등록 → 입력 → 전처리 → 자동설계 체인.
|
||||
|
||||
복제(`tools_clone_project.py`)와 다름 — 옛 결과를 뜨지 않고 **지금 로직으로 처음부터** 계산.
|
||||
화면 없이 서버 함수를 직접 부름(로그인 없음 · 소유자는 등록 함수의 user_id).
|
||||
|
||||
- 원본은 읽기만 — DB 행(등록 칸)과 `B03_FileInput/input` 파일을 복사해 씀.
|
||||
- 이름이 이미 있으면(삭제 안 된 행) 건너뜀 — 다시 돌려도 이어서 만듦.
|
||||
- 알림 메일 끔.
|
||||
|
||||
쓰는 법
|
||||
./venv/Scripts/python.exe db_management/tools_seed_projects.py \
|
||||
<원본 UUID> <결과 md> 이름:user_id ...
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
os.environ["SEND_ANALYSIS_COMPLETION_EMAIL"] = "False"
|
||||
sys.path.append(str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import aiomysql
|
||||
|
||||
import B03_FileInput.B03_FileInput_Service_WF1 as wf1
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import create_project
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
|
||||
from B03_FileInput.B03_FileInput_Repository import create_input_file
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
_complete_file_input_if_ready,
|
||||
_write_stage_metadata,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
|
||||
from B05_Profile.B05_Profile_Structures_Router import read_structures
|
||||
from common_util.common_util_initial_snapshot import is_design_failed, read_design_failure
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import close_db_pool, get_db_pool, init_db_pool
|
||||
|
||||
wf1.SEND_ANALYSIS_COMPLETION_EMAIL = False
|
||||
|
||||
|
||||
async def _no_mail(**_: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
wf1.send_analysis_error_email = _no_mail
|
||||
logger = logging.getLogger("seed")
|
||||
|
||||
REGISTER_FIELDS = (
|
||||
"region",
|
||||
"road_type",
|
||||
"project_year",
|
||||
"estimated_length_m",
|
||||
"memo",
|
||||
)
|
||||
TITLE_FIELDS = (
|
||||
"client_org",
|
||||
"project_number",
|
||||
"work_amount",
|
||||
"design_date",
|
||||
"pm_user_id",
|
||||
"field_lead_user_id",
|
||||
"designer_user_id",
|
||||
"logo_asset_id",
|
||||
"route_start_m",
|
||||
"route_end_m",
|
||||
)
|
||||
|
||||
|
||||
async def _one(sql: str, args: tuple) -> dict | None:
|
||||
async with get_db_pool().acquire() as c, c.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(sql, args)
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def _register_inputs(project_id: str, source_root: Path, source_files: list[dict]) -> int:
|
||||
"""원본 입력 파일을 같은 상대 경로로 복사하고 업로드 라우터와 같은 DB 등록을 한다."""
|
||||
pool = get_db_pool()
|
||||
project_uuid = UUID(project_id)
|
||||
row = await _one("SELECT storage_path FROM projects WHERE id=%s", (project_id,))
|
||||
project_root = Path(resolve_stored_project_path(row["storage_path"]))
|
||||
results: list[UploadedFileResult] = []
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
for item in source_files:
|
||||
relative_path = item["relative_path"]
|
||||
destination = project_root / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copyfile, source_root / relative_path, destination)
|
||||
metadata = await asyncio.to_thread(analyze_input_metadata, destination)
|
||||
size = destination.stat().st_size
|
||||
crs_epsg = metadata.get("epsg")
|
||||
input_file_id = await create_input_file(
|
||||
connection,
|
||||
project_id=project_uuid,
|
||||
file_type=item["file_type"],
|
||||
original_filename=item["original_filename"],
|
||||
relative_path=relative_path,
|
||||
file_size_bytes=size,
|
||||
upload_by=None,
|
||||
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
results.append(
|
||||
UploadedFileResult(
|
||||
input_file_id=input_file_id,
|
||||
original_filename=item["original_filename"],
|
||||
file_type=item["file_type"],
|
||||
relative_path=relative_path,
|
||||
size_bytes=size,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_uuid)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
_write_stage_metadata(project_root / "B03_FileInput", project_uuid, results)
|
||||
return point_cloud_input_id
|
||||
|
||||
|
||||
async def _check_b05(project_id: str) -> str:
|
||||
"""B05 를 여는 두 읽기(구조물 · 관 지점)와 관 하나의 options."""
|
||||
structures = await read_structures(UUID(project_id))
|
||||
pipes = await get_pipe_points(UUID(project_id))
|
||||
if not isinstance(pipes, dict) or not hasattr(structures, "structures"):
|
||||
return "B05 읽기 실패"
|
||||
points = pipes.get("pipe_points") or []
|
||||
first = (points[0].get("options") if points else None) or {}
|
||||
return (
|
||||
f"구조물 {len(structures.structures)} · 관 {len(points)} · "
|
||||
f"첫 관 options {json.dumps(first, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
|
||||
async def _seed(name: str, user_id: int, source: dict, source_files: list[dict]) -> dict:
|
||||
existing = await _one(
|
||||
"SELECT id FROM projects WHERE name=%s AND user_id=%s AND deleted_at IS NULL",
|
||||
(name, user_id),
|
||||
)
|
||||
if existing:
|
||||
project_id = existing["id"]
|
||||
return {
|
||||
"name": name,
|
||||
"user_id": user_id,
|
||||
"id": project_id,
|
||||
"note": "이미 있음",
|
||||
"b05": await _check_b05(project_id),
|
||||
}
|
||||
started = time.monotonic()
|
||||
created = await create_project(
|
||||
user_id=user_id,
|
||||
company_id=int(source["company_id"]),
|
||||
name=name,
|
||||
title_block={key: source[key] for key in TITLE_FIELDS},
|
||||
**{key: source[key] for key in REGISTER_FIELDS},
|
||||
)
|
||||
project_id = str(created["project_id"])
|
||||
logger.info("만듦 %s %s", name, project_id)
|
||||
input_id = await _register_inputs(
|
||||
project_id,
|
||||
Path(resolve_stored_project_path(source["storage_path"])),
|
||||
source_files,
|
||||
)
|
||||
await wf1.trigger_wf1_analysis_and_email(
|
||||
project_id=UUID(project_id), input_file_id=input_id, user_role="", started_by=user_id
|
||||
)
|
||||
row = await _one("SELECT status, storage_path FROM projects WHERE id=%s", (project_id,))
|
||||
root = Path(resolve_stored_project_path(row["storage_path"]))
|
||||
failure = read_design_failure(root) if is_design_failed(root) else ""
|
||||
return {
|
||||
"name": name,
|
||||
"user_id": user_id,
|
||||
"id": project_id,
|
||||
"status": row["status"],
|
||||
"failure": failure,
|
||||
"snapshot": (root / "initial_snapshot").is_dir(),
|
||||
"b05": await _check_b05(project_id),
|
||||
"minutes": round((time.monotonic() - started) / 60, 1),
|
||||
}
|
||||
|
||||
|
||||
async def main(source_id: str, report: Path, targets: list[tuple[str, int]]) -> None:
|
||||
await init_db_pool()
|
||||
try:
|
||||
source = await _one("SELECT * FROM projects WHERE id=%s", (source_id,))
|
||||
source_root = Path(resolve_stored_project_path(source["storage_path"]))
|
||||
meta = json.loads((source_root / "B03_FileInput/metadata.json").read_text("utf-8"))
|
||||
source_files = meta["files"]
|
||||
rows = []
|
||||
for name, user_id in targets:
|
||||
try:
|
||||
result = await _seed(name, user_id, source, source_files)
|
||||
except Exception as exc:
|
||||
logger.exception("실패 %s", name)
|
||||
result = {"name": name, "user_id": user_id, "id": "", "failure": repr(exc)}
|
||||
logger.info("결과 %s", json.dumps(result, ensure_ascii=False))
|
||||
rows.append(result)
|
||||
_write_report(report, source_id, rows)
|
||||
finally:
|
||||
await close_db_pool()
|
||||
|
||||
|
||||
def _write_report(report: Path, source_id: str, rows: list[dict]) -> None:
|
||||
lines = [
|
||||
f"# 새 검증 프로젝트 (원본 {source_id} 입력 · 새 로직 처음부터)",
|
||||
"",
|
||||
"| 이름 | 소유 user | 프로젝트 id | 상태 | 초기값 | B05 확인 | 실패 | 분 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for r in rows:
|
||||
lines.append(
|
||||
f"| {r['name']} | {r['user_id']} | {r['id']} | {r.get('status', r.get('note', ''))} "
|
||||
f"| {r.get('snapshot', '')} | {r.get('b05', '')} | {r.get('failure', '')} "
|
||||
f"| {r.get('minutes', '')} |"
|
||||
)
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
||||
pairs = [arg.rsplit(":", 1) for arg in sys.argv[3:]]
|
||||
asyncio.run(main(sys.argv[1], Path(sys.argv[2]), [(n, int(u)) for n, u in pairs]))
|
||||
@@ -295,3 +295,15 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의
|
||||
B 방향이 맞겠네. 새 프로젝트를 만드는건 문제없어보여. 이때 개별 워크트리들이 검증할수 있는 프로젝트를 각각지정해주는게 좋겠어. 그리고 추가로 엄상준과 박동철 프로젝트도 같이 만들어줘. 같은회사 검증자 들이야. 이 사람들도 본인의 프로젝트로 검증할거야.
|
||||
이전 프로젝트는 그냥 삭제해
|
||||
전부 다 손대도 상관없음 프로젝트 전역 초기
|
||||
|
||||
(10-6 사용자)
|
||||
그런데 이전 프로젝트를 삭제할때 대시보드에서 리스트가 나오는데 정리해줘.
|
||||
|
||||
(10-6 사용자)
|
||||
내가 원지반 제약에의 구조물 형상 생성 및 제어 제약에 대해 락을 풀어달라고 했는데 반영이 안된것 같아. 확인해봐.
|
||||
|
||||
(10-6 사용자)
|
||||
다음으로 종단 패널의 종단도에서 알약선택시 나오는 선택정보 창에서 내용은 옵션 제목은 필요없고 구분기호 하나 넣고 옵션값만 구조물 이름 행과 2행은 옵션값만으로 표현해도 됨
|
||||
|
||||
(10-6 사용자)
|
||||
횡단도에서 형상의 제약조건이 어떤게 있는지 확인 좀 해줘. 배관과 흙막이, 옹벽관련 내용들만 일단 찾아줘.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만.
|
||||
|
||||
• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만)
|
||||
• 2행 — 1행 = 이름 + 기준 측점 · 2행 = 컨테이너마다 첫 옵션 값만 「 · 」 로 이음(54-46 · 제목 없음)
|
||||
• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹)
|
||||
• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀
|
||||
• 빈 값은 건너뜀 · 값 auto = 「(자동)」 · 값이 하나도 없으면 1행만 · 메모 · 그 밖 옵션은 안 씀
|
||||
• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다.
|
||||
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`).
|
||||
"""
|
||||
@@ -33,56 +33,37 @@ def out():
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_배관_컨테이너마다_첫_값_한_줄(out):
|
||||
assert out["pipeFull"] == [
|
||||
"이름",
|
||||
"5+0",
|
||||
"관: 관경 1200",
|
||||
"관 보호공(유입): 구조 집수정",
|
||||
"관 보호공(유출): 구조 흙막이",
|
||||
"흙막이 1: 형태 돌망태", # 구조 칸 없음(54-30)
|
||||
"도수로/돌붙임 1: 구조 도수로",
|
||||
]
|
||||
def test_배관_컨테이너마다_첫_값만_한_줄로_이음(out):
|
||||
# 흙막이 1 은 구조 칸 없음 — 형태(54-30)
|
||||
assert out["pipeFull"] == ["이름 5+0", "1200 · 집수정 · 흙막이 · 돌망태 · 도수로"]
|
||||
|
||||
|
||||
def test_빈_값은_등록부_기본값에_기본_표시(out):
|
||||
assert out["pipeBlank"] == [
|
||||
"이름",
|
||||
"5+0",
|
||||
"관: 관경 1000 (기본)",
|
||||
"관 보호공(유입): 구조 흙막이 (기본)",
|
||||
"관 보호공(유출): 구조 —",
|
||||
]
|
||||
def test_빈_값은_건너뜀(out):
|
||||
assert out["pipeBlank"] == ["이름 5+0"]
|
||||
|
||||
|
||||
def test_흙막이_상자_수는_밖에서_정한_단_수(out):
|
||||
assert out["pipeCount"][-2:] == ["흙막이 1: 형태 —", "흙막이 2: 형태 —"]
|
||||
assert len(out["pipeCount"]) == 2 + 3 + 2
|
||||
# 저장 1 벌(형태 빈 값) · 모자란 둘째도 빈 값 — 둘 다 건너뜀
|
||||
assert out["pipeCount"] == ["이름 5+0"]
|
||||
|
||||
|
||||
def test_박스_본체_규격은_폭_높이로_되찾음(out):
|
||||
assert out["boxPreset"] == [
|
||||
"이름",
|
||||
"5+0",
|
||||
"본체: 본체 규격 3.0 × 3.0",
|
||||
"유입 구조: 구조 날개벽",
|
||||
"유출 구조: 구조 —",
|
||||
]
|
||||
assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)"
|
||||
assert out["boxPreset"] == ["이름 5+0", "3.0 × 3.0 · 날개벽"]
|
||||
assert out["boxBlank"] == ["이름 5+0"]
|
||||
|
||||
|
||||
def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out):
|
||||
assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"]
|
||||
assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"]
|
||||
# 끈 칸(높이)은 건너뜀 · 빈 값은 기본값
|
||||
assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"]
|
||||
assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"]
|
||||
assert out["underdrain"] == ["이름 5+0", "0.6m"]
|
||||
assert out["underdrainBlank"] == ["이름 5+0"]
|
||||
# 끈 칸(높이)은 건너뜀
|
||||
assert out["wall"] == ["이름 5+0"]
|
||||
assert out["wallAuto"] == ["이름 5+0", "(자동)"]
|
||||
|
||||
|
||||
def test_컨테이너가_없으면_이름과_기준_측점만(out):
|
||||
assert out["mirror"] == ["이름", "5+0"]
|
||||
assert out["etcEmpty"] == ["이름", "5+0"]
|
||||
assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"]
|
||||
def test_컨테이너가_없으면_1행만(out):
|
||||
assert out["mirror"] == ["이름 5+0"]
|
||||
assert out["etcEmpty"] == ["이름 5+0"]
|
||||
assert out["etcNote"] == ["이름 5+0", "시험 비고"]
|
||||
|
||||
|
||||
def test_라벨은_단추층_위로_비켜_뜬다():
|
||||
|
||||
@@ -49,9 +49,10 @@ def test_chat_searches_all_knowledge_markdown_except_excluded_folders(monkeypatc
|
||||
result = asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="임도 기준"), {}))
|
||||
assert result["answer"] == "설명 [1]"
|
||||
paths = {source["path"] for source in result["sources"]}
|
||||
assert paths == {"original/법률/현행.md", "technical_info/_검토.md"}
|
||||
assert paths == {"original/법률/현행.md"}
|
||||
assert "실무문서" not in sent["prompt"]
|
||||
assert "비공개 사례" not in sent["prompt"]
|
||||
assert "STmate" not in sent["prompt"]
|
||||
assert "다산소프트" not in sent["prompt"]
|
||||
assert "기술정보 안내" not in sent["prompt"]
|
||||
chat._sections.cache_clear()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""화면 취향 저장 — 지워진 프로젝트 id 가 박힌 키는 버림 · 나머지 키는 그대로."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import B01_Dashboard.B01_Dashboard_Repository as repo
|
||||
|
||||
LIVE = "226fc805-26c8-4f5a-9956-bacb200e10a5"
|
||||
DEAD = "936be972-11bc-46c2-8bf3-b15d8de7df0d"
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
self.rows = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args):
|
||||
if sql.startswith("SELECT id FROM projects"):
|
||||
self.rows = [(i,) for i in args if i == LIVE]
|
||||
else:
|
||||
self.store["saved"] = json.loads(args[1])
|
||||
|
||||
async def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return _Cursor(self.store)
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def acquire(self):
|
||||
return _Conn(self.store)
|
||||
|
||||
|
||||
def test_dead_project_keys_dropped(monkeypatch):
|
||||
store = {}
|
||||
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(store))
|
||||
prefs = {
|
||||
f"aislo:pref:routeedit-view:{LIVE}": "a",
|
||||
f"aislo:pref:routeedit-view:{DEAD}": "b",
|
||||
"aislo:pref:table-open": "true",
|
||||
}
|
||||
asyncio.run(repo.save_user_ui_prefs(3, prefs))
|
||||
assert store["saved"] == {
|
||||
f"aislo:pref:routeedit-view:{LIVE}": "a",
|
||||
"aislo:pref:table-open": "true",
|
||||
}
|
||||
Reference in New Issue
Block a user