diff --git a/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py b/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py index 26be22a0e..42179624c 100644 --- a/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py +++ b/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py @@ -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" diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 658c042fb..8907556f1 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -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)""", diff --git a/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts b/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts index 519ba5bf9..b718490a0 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts @@ -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 = ``; 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(); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 93d70fa3c..16efe22a4 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts index 8023aecb8..9c49247d6 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -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, 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 ?? ""}` : ""; }); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index e05b782d7..0fc00d26a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -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[], diff --git a/db_management/tools_seed_projects.py b/db_management/tools_seed_projects.py new file mode 100644 index 000000000..848e130aa --- /dev/null +++ b/db_management/tools_seed_projects.py @@ -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])) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 794ffbed8..41aeb8a33 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -295,3 +295,15 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 B 방향이 맞겠네. 새 프로젝트를 만드는건 문제없어보여. 이때 개별 워크트리들이 검증할수 있는 프로젝트를 각각지정해주는게 좋겠어. 그리고 추가로 엄상준과 박동철 프로젝트도 같이 만들어줘. 같은회사 검증자 들이야. 이 사람들도 본인의 프로젝트로 검증할거야. 이전 프로젝트는 그냥 삭제해 전부 다 손대도 상관없음 프로젝트 전역 초기 + +(10-6 사용자) +그런데 이전 프로젝트를 삭제할때 대시보드에서 리스트가 나오는데 정리해줘. + +(10-6 사용자) +내가 원지반 제약에의 구조물 형상 생성 및 제어 제약에 대해 락을 풀어달라고 했는데 반영이 안된것 같아. 확인해봐. + +(10-6 사용자) +다음으로 종단 패널의 종단도에서 알약선택시 나오는 선택정보 창에서 내용은 옵션 제목은 필요없고 구분기호 하나 넣고 옵션값만 구조물 이름 행과 2행은 옵션값만으로 표현해도 됨 + +(10-6 사용자) +횡단도에서 형상의 제약조건이 어떤게 있는지 확인 좀 해줘. 배관과 흙막이, 옹벽관련 내용들만 일단 찾아줘. diff --git a/resources/tester/test_54_14_balloon_lines.py b/resources/tester/test_54_14_balloon_lines.py index aa9b92539..24361d7dc 100644 --- a/resources/tester/test_54_14_balloon_lines.py +++ b/resources/tester/test_54_14_balloon_lines.py @@ -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_라벨은_단추층_위로_비켜_뜬다(): diff --git a/resources/tester/test_b01_knowledge_chat.py b/resources/tester/test_b01_knowledge_chat.py index 237e79bb1..bf7667c07 100644 --- a/resources/tester/test_b01_knowledge_chat.py +++ b/resources/tester/test_b01_knowledge_chat.py @@ -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() diff --git a/resources/tester/test_b01_ui_prefs_dead_project.py b/resources/tester/test_b01_ui_prefs_dead_project.py new file mode 100644 index 000000000..c1e1e98f5 --- /dev/null +++ b/resources/tester/test_b01_ui_prefs_dead_project.py @@ -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", + }