@dssp/dcsp 1.0.2 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/bootstrap.js +13 -10
- package/dist-client/bootstrap.js.map +1 -1
- package/dist-client/components/kpi-2d-lookup-chart.d.ts +50 -0
- package/dist-client/components/kpi-2d-lookup-chart.js +263 -0
- package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -0
- package/dist-client/components/kpi-boxplot-chart.d.ts +31 -0
- package/dist-client/components/kpi-boxplot-chart.js +306 -0
- package/dist-client/components/kpi-boxplot-chart.js.map +1 -0
- package/dist-client/components/kpi-lookup-chart.d.ts +58 -0
- package/dist-client/components/kpi-lookup-chart.js +464 -0
- package/dist-client/components/kpi-lookup-chart.js.map +1 -0
- package/dist-client/components/kpi-mini-trend-chart.d.ts +14 -0
- package/dist-client/components/kpi-mini-trend-chart.js +180 -0
- package/dist-client/components/kpi-mini-trend-chart.js.map +1 -0
- package/dist-client/components/kpi-radar-chart.d.ts +17 -0
- package/dist-client/components/kpi-radar-chart.js +259 -0
- package/dist-client/components/kpi-radar-chart.js.map +1 -0
- package/dist-client/components/kpi-single-boxplot-chart.d.ts +36 -0
- package/dist-client/components/kpi-single-boxplot-chart.js +417 -0
- package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -0
- package/dist-client/components/kpi-step-lookup-chart.d.ts +33 -0
- package/dist-client/components/kpi-step-lookup-chart.js +181 -0
- package/dist-client/components/kpi-step-lookup-chart.js.map +1 -0
- package/dist-client/components/kpi-trend-chart.d.ts +25 -0
- package/dist-client/components/kpi-trend-chart.js +241 -0
- package/dist-client/components/kpi-trend-chart.js.map +1 -0
- package/dist-client/google-map/common-google-map.d.ts +35 -0
- package/dist-client/google-map/common-google-map.js +349 -0
- package/dist-client/google-map/common-google-map.js.map +1 -0
- package/dist-client/google-map/google-map-loader.d.ts +6 -0
- package/dist-client/google-map/google-map-loader.js +23 -0
- package/dist-client/google-map/google-map-loader.js.map +1 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.d.ts +47 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.js +393 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.js.map +1 -0
- package/dist-client/pages/kpi-admin/kpi-system-guide.d.ts +18 -0
- package/dist-client/pages/kpi-admin/kpi-system-guide.js +696 -0
- package/dist-client/pages/kpi-admin/kpi-system-guide.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.d.ts +46 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +945 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +34 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +238 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +37 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +689 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +57 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +866 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab1-plan.d.ts +69 -0
- package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js +790 -0
- package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab2-rating.d.ts +22 -0
- package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js +344 -0
- package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab3-upload.d.ts +21 -0
- package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js +335 -0
- package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.d.ts +51 -0
- package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js +693 -0
- package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js.map +1 -0
- package/dist-client/pages/sv-project-complete.d.ts +31 -0
- package/dist-client/pages/sv-project-complete.js +364 -0
- package/dist-client/pages/sv-project-complete.js.map +1 -0
- package/dist-client/pages/sv-project-detail.d.ts +73 -0
- package/dist-client/pages/sv-project-detail.js +1387 -0
- package/dist-client/pages/sv-project-detail.js.map +1 -0
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +15 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/shared/complete-api.d.ts +45 -0
- package/dist-client/shared/complete-api.js +274 -0
- package/dist-client/shared/complete-api.js.map +1 -0
- package/dist-client/shared/func.d.ts +2 -0
- package/dist-client/shared/func.js +22 -0
- package/dist-client/shared/func.js.map +1 -0
- package/dist-client/shared/integration-fetch.d.ts +35 -0
- package/dist-client/shared/integration-fetch.js +53 -0
- package/dist-client/shared/integration-fetch.js.map +1 -0
- package/dist-client/shared/kpi-project-sync.d.ts +16 -0
- package/dist-client/shared/kpi-project-sync.js +129 -0
- package/dist-client/shared/kpi-project-sync.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/viewparts/menu-tools.d.ts +7 -0
- package/dist-client/viewparts/menu-tools.js +168 -21
- package/dist-client/viewparts/menu-tools.js.map +1 -1
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +5 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/middlewares/index.d.ts +7 -0
- package/dist-server/middlewares/index.js +100 -0
- package/dist-server/middlewares/index.js.map +1 -0
- package/dist-server/service/index.d.ts +4 -0
- package/dist-server/service/index.js +19 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/kpi-metric-value/index.d.ts +4 -0
- package/dist-server/service/kpi-metric-value/index.js +8 -0
- package/dist-server/service/kpi-metric-value/index.js.map +1 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.d.ts +198 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +1067 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-query.d.ts +15 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js +72 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js.map +1 -0
- package/dist-server/service/kpi-stat/index.d.ts +4 -0
- package/dist-server/service/kpi-stat/index.js +8 -0
- package/dist-server/service/kpi-stat/index.js.map +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +13 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js +751 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +32 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js +126 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -0
- package/dist-server/service/kpi-value/index.d.ts +3 -0
- package/dist-server/service/kpi-value/index.js +7 -0
- package/dist-server/service/kpi-value/index.js.map +1 -0
- package/dist-server/service/kpi-value/kpi-value-query.d.ts +8 -0
- package/dist-server/service/kpi-value/kpi-value-query.js +90 -0
- package/dist-server/service/kpi-value/kpi-value-query.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/python/RUNNER_CONVERSION_GUIDE.md +81 -0
- package/python/doc_summary_engine_handoff/.env.example +21 -0
- package/python/doc_summary_engine_handoff/HANDOFF.md +266 -0
- package/python/doc_summary_engine_handoff/README.md +71 -0
- package/python/doc_summary_engine_handoff/__main__.py +159 -0
- package/python/doc_summary_engine_handoff/api.py +165 -0
- package/python/doc_summary_engine_handoff/extract.py +290 -0
- package/python/doc_summary_engine_handoff/requirements.txt +15 -0
- package/python/doc_summary_engine_handoff/summarize.py +247 -0
- package/python/inspection_ai_engine_handoff/.env.example +22 -0
- package/python/inspection_ai_engine_handoff/HANDOFF.md +541 -0
- package/python/inspection_ai_engine_handoff/README.md +83 -0
- package/python/inspection_ai_engine_handoff/__main__.py +169 -0
- package/python/inspection_ai_engine_handoff/activities.py +147 -0
- package/python/inspection_ai_engine_handoff/api.py +273 -0
- package/python/inspection_ai_engine_handoff/checklist.py +53 -0
- package/python/inspection_ai_engine_handoff/cv_handlers.py +46 -0
- package/python/inspection_ai_engine_handoff/cv_model_registry.py +252 -0
- package/python/inspection_ai_engine_handoff/cv_models/README.md +58 -0
- package/python/inspection_ai_engine_handoff/cv_models/_example_frame_alignment/handler.py +39 -0
- package/python/inspection_ai_engine_handoff/cv_models/_example_frame_alignment/model.json +22 -0
- package/python/inspection_ai_engine_handoff/engine.py +333 -0
- package/python/inspection_ai_engine_handoff/requirements.txt +12 -0
- package/python/inspection_ai_engine_handoff/router.py +200 -0
- package/python/inspection_ai_engine_handoff/shot_catalog.py +79 -0
- package/python/inspection_ai_engine_handoff/shot_planner.py +278 -0
- package/python/inspection_ai_engine_handoff/vlm.py +335 -0
- package/python/spec-matching-0.5.5/README.md +113 -0
- package/python/spec-matching-0.5.5/__main__.py +331 -0
- package/python/spec-matching-0.5.5/kcs.csv +6028 -0
- package/python/spec-matching-0.5.5/kcs_embeddings.meta.json +1 -0
- package/python/spec-matching-0.5.5/kcs_embeddings.npy +0 -0
- package/python/spec-matching-0.5.5/requirements.txt +3 -0
- package/python/spec-matching-0.5.5//342/230/2057 SF-TD4 /341/204/221/341/205/263/341/204/205/341/205/251/341/204/200/341/205/263/341/204/205/341/205/242/341/206/267/341/204/211/341/205/245/341/206/257/341/204/200/341/205/250/341/204/211/341/205/245 /341/204/213/341/205/243/341/206/274/341/204/211/341/205/265/341/206/250_V1.0.hwp +0 -0
- package/schema.graphql +1274 -22
- package/things-factory.config.js +7 -1
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""문서 AI 브리핑 — HTTP API (FastAPI).
|
|
2
|
+
|
|
3
|
+
엔드포인트:
|
|
4
|
+
GET /health 서버 상태 + 지원 포맷
|
|
5
|
+
POST /brief-doc 문서 파일 1개 → 한 줄 AI 브리핑
|
|
6
|
+
|
|
7
|
+
실행:
|
|
8
|
+
python api.py
|
|
9
|
+
→ http://localhost:8100 (Swagger: /docs)
|
|
10
|
+
|
|
11
|
+
설계:
|
|
12
|
+
- Stateless. 결과 저장은 플랫폼 측 DB.
|
|
13
|
+
- 권장 호출 시점: 문서 '업로드 시 1회' 호출해서 브리핑을 받아 저장 →
|
|
14
|
+
이후 문서 클릭 시엔 저장된 값을 표시 (재호출 없음 → 빠르고 저렴).
|
|
15
|
+
- 텍스트를 못 읽는 문서(스캔/도면/사진/DRM)는 briefing=null 로 반환
|
|
16
|
+
(환각 대신 빈칸).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
25
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
26
|
+
from fastapi.responses import JSONResponse
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from dotenv import load_dotenv
|
|
31
|
+
load_dotenv()
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
from extract import extract_text
|
|
36
|
+
from summarize import brief_document
|
|
37
|
+
|
|
38
|
+
DEFAULT_PORT = int(os.environ.get("DOC_API_PORT", "8100"))
|
|
39
|
+
MAX_BYTES = int(os.environ.get("DOC_API_MAX_BYTES", str(30 * 1024 * 1024))) # 30MB
|
|
40
|
+
ALLOWED_ORIGINS = os.environ.get("DOC_API_ALLOWED_ORIGINS", "*").split(",")
|
|
41
|
+
|
|
42
|
+
SUPPORTED = ["hwp", "hwpx", "pdf", "xlsx", "docx", "pptx"]
|
|
43
|
+
|
|
44
|
+
app = FastAPI(
|
|
45
|
+
title="시공감리 문서 AI 브리핑 API",
|
|
46
|
+
version="1.0.0",
|
|
47
|
+
description=(
|
|
48
|
+
"시공감리 플랫폼의 「현장 문서 확인」 문서를 받아 한 줄 AI 브리핑을 반환합니다.\n\n"
|
|
49
|
+
"HWP(5.x)/HWPX/PDF/XLSX/DOCX/PPTX 의 본문 텍스트를 추출해 요약.\n"
|
|
50
|
+
"텍스트를 못 읽는 문서(스캔·도면·사진·DRM)는 briefing=null (환각 방지)."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
app.add_middleware(
|
|
55
|
+
CORSMiddleware,
|
|
56
|
+
allow_origins=ALLOWED_ORIGINS,
|
|
57
|
+
allow_credentials=True,
|
|
58
|
+
allow_methods=["*"],
|
|
59
|
+
allow_headers=["*"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HealthResponse(BaseModel):
|
|
64
|
+
ok: bool
|
|
65
|
+
server_time: float
|
|
66
|
+
supported_formats: list[str]
|
|
67
|
+
model: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.get("/health", response_model=HealthResponse)
|
|
71
|
+
def health():
|
|
72
|
+
return HealthResponse(
|
|
73
|
+
ok=True,
|
|
74
|
+
server_time=time.time(),
|
|
75
|
+
supported_formats=SUPPORTED,
|
|
76
|
+
model=os.environ.get("OPENAI_MODEL", "gpt-5.5"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.post("/brief-doc")
|
|
81
|
+
async def post_brief_doc(
|
|
82
|
+
file: UploadFile = File(..., description="문서 파일 1개. HWP/HWPX/PDF/XLSX/DOCX/PPTX."),
|
|
83
|
+
category: Optional[str] = Form("", description="문서 분류(대/중/소분류) 문자열. 근거 힌트, 선택."),
|
|
84
|
+
filename: Optional[str] = Form(None, description="파일명 override (없으면 업로드 파일명 사용)."),
|
|
85
|
+
):
|
|
86
|
+
"""문서 1개 → 한 줄 AI 브리핑.
|
|
87
|
+
|
|
88
|
+
multipart/form-data:
|
|
89
|
+
- file: 문서 파일 (필수)
|
|
90
|
+
- category: 분류 문자열 (선택, 예: "사업관리 · 공사작업일보 · 민원처리보고")
|
|
91
|
+
- filename: 파일명 override (선택)
|
|
92
|
+
|
|
93
|
+
응답:
|
|
94
|
+
{
|
|
95
|
+
"ok": true,
|
|
96
|
+
"filename": "문서명 04.hwp",
|
|
97
|
+
"kind": "hwp",
|
|
98
|
+
"char_count": 812,
|
|
99
|
+
"briefing": "...한 줄 브리핑...", // 못 읽으면 null
|
|
100
|
+
"doc_type": "공문",
|
|
101
|
+
"has_content": true,
|
|
102
|
+
"note": "", // briefing=null 사유
|
|
103
|
+
"model": "gpt-5.5",
|
|
104
|
+
"elapsed_sec": 2.1
|
|
105
|
+
}
|
|
106
|
+
"""
|
|
107
|
+
name = filename or file.filename or "(unknown)"
|
|
108
|
+
data = await file.read()
|
|
109
|
+
|
|
110
|
+
if not data:
|
|
111
|
+
raise HTTPException(status_code=400, detail="빈 파일입니다.")
|
|
112
|
+
if len(data) > MAX_BYTES:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=413,
|
|
115
|
+
detail=f"파일 크기 초과 ({len(data)} > {MAX_BYTES} bytes).",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# 1) 텍스트 추출 — 못 읽으면 LLM 호출 없이 빈칸 반환 (환각 방지 + 비용 0).
|
|
119
|
+
ex = extract_text(data, name)
|
|
120
|
+
if not ex.ok:
|
|
121
|
+
return JSONResponse({
|
|
122
|
+
"ok": True,
|
|
123
|
+
"filename": name,
|
|
124
|
+
"kind": ex.kind,
|
|
125
|
+
"char_count": ex.char_count,
|
|
126
|
+
"briefing": None,
|
|
127
|
+
"doc_type": "기타",
|
|
128
|
+
"has_content": False,
|
|
129
|
+
"note": ex.reason,
|
|
130
|
+
"model": os.environ.get("OPENAI_MODEL", "gpt-5.5"),
|
|
131
|
+
"elapsed_sec": 0.0,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
# 2) OpenAI 클라이언트
|
|
135
|
+
try:
|
|
136
|
+
from openai import OpenAI
|
|
137
|
+
client = OpenAI()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=500,
|
|
141
|
+
detail=f"OpenAI 클라이언트 초기화 실패 (OPENAI_API_KEY 확인): {e}",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# 3) 한 줄 브리핑
|
|
145
|
+
result = brief_document(
|
|
146
|
+
ex.text, filename=name, category=category or "", client=client,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return JSONResponse({
|
|
150
|
+
"ok": True,
|
|
151
|
+
"filename": name,
|
|
152
|
+
"kind": ex.kind,
|
|
153
|
+
"char_count": ex.char_count,
|
|
154
|
+
"briefing": result.briefing,
|
|
155
|
+
"doc_type": result.doc_type,
|
|
156
|
+
"has_content": result.has_content,
|
|
157
|
+
"note": result.note,
|
|
158
|
+
"model": result.model,
|
|
159
|
+
"elapsed_sec": result.elapsed_sec,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
import uvicorn
|
|
165
|
+
uvicorn.run("api:app", host="0.0.0.0", port=DEFAULT_PORT, reload=False)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""문서 파일 → 본문 텍스트 추출.
|
|
2
|
+
|
|
3
|
+
지원 포맷:
|
|
4
|
+
- .hwp (한글 5.x 바이너리) → olefile + zlib (BodyText 레코드 직접 파싱)
|
|
5
|
+
- .hwpx (한글 신포맷, OWPML) → zip + XML 파싱 (표준 라이브러리)
|
|
6
|
+
- .pdf → PyMuPDF (fitz)
|
|
7
|
+
- .xlsx → openpyxl (시트 → 마크다운 표)
|
|
8
|
+
- .docx → python-docx
|
|
9
|
+
- .pptx → python-pptx (슬라이드 텍스트 + 노트)
|
|
10
|
+
|
|
11
|
+
설계 원칙 — 환각 방지의 1차 방어선:
|
|
12
|
+
텍스트를 못 읽거나(스캔 PDF, DRM hwp, 도면·사진) 본문이 너무 빈약하면
|
|
13
|
+
'추측해서 채우지' 않고 ok=False 로 돌려준다. 그러면 상위(summarize/api)는
|
|
14
|
+
LLM 을 호출하지 않고 브리핑을 빈칸(null)으로 둔다.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import struct
|
|
22
|
+
import zipfile
|
|
23
|
+
import zlib
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
# 본문으로 인정할 최소 글자 수 (공백 제외). 이보다 적으면 '본문 없음' 처리.
|
|
28
|
+
MIN_TEXT_CHARS = int(os.environ.get("DOC_MIN_TEXT_CHARS", "20"))
|
|
29
|
+
# 엑셀 시트당 읽을 최대 행 수 (한 줄 브리핑엔 앞부분이면 충분).
|
|
30
|
+
MAX_XLSX_ROWS = int(os.environ.get("DOC_MAX_XLSX_ROWS", "200"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ExtractResult:
|
|
35
|
+
ok: bool # 본문 추출 성공 + 충분한 분량 여부
|
|
36
|
+
text: str # 추출된 본문 (실패 시 "")
|
|
37
|
+
kind: str # 파일 종류 ("hwp"|"hwpx"|"pdf"|"xlsx"|"docx"|"unknown"|...)
|
|
38
|
+
char_count: int # 공백 제외 글자 수
|
|
39
|
+
reason: str = "" # ok=False 일 때 사유 (사람용)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── 포맷별 추출기 ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
# HWP 5.x 본문 텍스트 레코드 태그 (HWPTAG_PARA_TEXT)
|
|
45
|
+
_HWP_PARA_TEXT_TAG = 67
|
|
46
|
+
# 부가 데이터를 포함해 8 WCHAR를 차지하는 인라인/확장 컨트롤 문자 코드
|
|
47
|
+
_HWP_EXTENDED_CTRL = frozenset(range(1, 10)) | {11, 12} | frozenset(range(14, 24))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _hwp_records(stream: bytes):
|
|
51
|
+
"""HWP 레코드 스트림 순회 → (tag, payload) 생성.
|
|
52
|
+
레코드 헤더 4바이트: tag(10bit) | level(10bit) | size(12bit). size=0xFFF면 뒤 4바이트가 실제 크기."""
|
|
53
|
+
i, n = 0, len(stream)
|
|
54
|
+
while i + 4 <= n:
|
|
55
|
+
(hdr,) = struct.unpack_from("<I", stream, i)
|
|
56
|
+
i += 4
|
|
57
|
+
tag = hdr & 0x3FF
|
|
58
|
+
size = (hdr >> 20) & 0xFFF
|
|
59
|
+
if size == 0xFFF:
|
|
60
|
+
if i + 4 > n:
|
|
61
|
+
break
|
|
62
|
+
(size,) = struct.unpack_from("<I", stream, i)
|
|
63
|
+
i += 4
|
|
64
|
+
yield tag, stream[i : i + size]
|
|
65
|
+
i += size
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _hwp_para_text(payload: bytes) -> str:
|
|
69
|
+
"""PARA_TEXT 페이로드(WCHAR 배열) → 텍스트. 컨트롤 문자는 건너뛰거나 줄바꿈으로 치환."""
|
|
70
|
+
u = payload.decode("utf-16-le", errors="ignore")
|
|
71
|
+
out: list[str] = []
|
|
72
|
+
i = 0
|
|
73
|
+
while i < len(u):
|
|
74
|
+
c = ord(u[i])
|
|
75
|
+
if c in (10, 13):
|
|
76
|
+
out.append("\n")
|
|
77
|
+
i += 1
|
|
78
|
+
elif c in _HWP_EXTENDED_CTRL:
|
|
79
|
+
i += 8 # 컨트롤 + 부가데이터 7 WCHAR
|
|
80
|
+
elif c < 32:
|
|
81
|
+
i += 1
|
|
82
|
+
else:
|
|
83
|
+
out.append(u[i])
|
|
84
|
+
i += 1
|
|
85
|
+
return "".join(out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_hwp(data: bytes) -> str:
|
|
89
|
+
"""한글 5.x (.hwp, OLE 복합문서) → 텍스트. BodyText 섹션 레코드에서 본문을 직접 파싱.
|
|
90
|
+
(pyhwp/hwp5txt 의존 제거 — 실행 환경에 CLI가 없어도 동작)"""
|
|
91
|
+
import olefile
|
|
92
|
+
|
|
93
|
+
ole = olefile.OleFileIO(io.BytesIO(data))
|
|
94
|
+
try:
|
|
95
|
+
if not ole.exists("FileHeader"):
|
|
96
|
+
raise RuntimeError("한글 5.x 형식이 아님 (구형식/손상 파일)")
|
|
97
|
+
header = ole.openstream("FileHeader").read()
|
|
98
|
+
(flags,) = struct.unpack_from("<I", header, 36)
|
|
99
|
+
if flags & 0x2:
|
|
100
|
+
raise RuntimeError("암호화된 문서")
|
|
101
|
+
compressed = bool(flags & 0x1)
|
|
102
|
+
|
|
103
|
+
# BodyText/Section0, Section1, ... 순서대로 (배포용 문서는 ViewText라 본문 없음 → 빈칸 처리)
|
|
104
|
+
sections = sorted(
|
|
105
|
+
(e for e in ole.listdir() if e[0] == "BodyText"),
|
|
106
|
+
key=lambda e: int(re.sub(r"\D", "", e[-1]) or 0),
|
|
107
|
+
)
|
|
108
|
+
parts: list[str] = []
|
|
109
|
+
for entry in sections:
|
|
110
|
+
raw = ole.openstream(entry).read()
|
|
111
|
+
if compressed:
|
|
112
|
+
raw = zlib.decompress(raw, -15)
|
|
113
|
+
for tag, payload in _hwp_records(raw):
|
|
114
|
+
if tag == _HWP_PARA_TEXT_TAG:
|
|
115
|
+
t = _hwp_para_text(payload)
|
|
116
|
+
if t.strip():
|
|
117
|
+
parts.append(t)
|
|
118
|
+
return "\n".join(parts)
|
|
119
|
+
finally:
|
|
120
|
+
ole.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _extract_hwpx(data: bytes) -> str:
|
|
124
|
+
"""한글 신포맷 (.hwpx, OWPML) → 텍스트. zip 안 section XML 의 텍스트 런(t) 추출."""
|
|
125
|
+
import xml.etree.ElementTree as ET
|
|
126
|
+
|
|
127
|
+
parts: list[str] = []
|
|
128
|
+
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
|
129
|
+
names = sorted(
|
|
130
|
+
n for n in z.namelist()
|
|
131
|
+
if n.startswith("Contents/") and n.lower().endswith(".xml") and "section" in n.lower()
|
|
132
|
+
)
|
|
133
|
+
for n in names:
|
|
134
|
+
try:
|
|
135
|
+
root = ET.fromstring(z.read(n))
|
|
136
|
+
except ET.ParseError:
|
|
137
|
+
continue
|
|
138
|
+
for el in root.iter():
|
|
139
|
+
tag = el.tag.split("}")[-1] # 네임스페이스 제거 (hp:t → t)
|
|
140
|
+
if tag == "p": # 문단 경계 → 줄바꿈
|
|
141
|
+
parts.append("\n")
|
|
142
|
+
elif tag == "t" and el.text: # 텍스트 런
|
|
143
|
+
parts.append(el.text)
|
|
144
|
+
return "".join(parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_pdf(data: bytes) -> str:
|
|
148
|
+
"""PDF → 텍스트. (스캔 PDF 는 텍스트가 거의 안 나옴 → 상위에서 빈칸 처리)"""
|
|
149
|
+
import fitz # PyMuPDF
|
|
150
|
+
|
|
151
|
+
pages: list[str] = []
|
|
152
|
+
with fitz.open(stream=data, filetype="pdf") as doc:
|
|
153
|
+
for page in doc:
|
|
154
|
+
pages.append(page.get_text())
|
|
155
|
+
return "\n".join(pages)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _extract_xlsx(data: bytes) -> str:
|
|
159
|
+
"""XLSX → 시트별 마크다운 표. 표 구조를 모델이 보게 해서 브리핑 정확도↑."""
|
|
160
|
+
from openpyxl import load_workbook
|
|
161
|
+
|
|
162
|
+
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
|
163
|
+
try:
|
|
164
|
+
blocks: list[str] = []
|
|
165
|
+
for ws in wb.worksheets:
|
|
166
|
+
rows: list[str] = []
|
|
167
|
+
for i, row in enumerate(ws.iter_rows(values_only=True)):
|
|
168
|
+
if i >= MAX_XLSX_ROWS:
|
|
169
|
+
rows.append("… (이하 행 생략)")
|
|
170
|
+
break
|
|
171
|
+
cells = ["" if c is None else str(c) for c in row]
|
|
172
|
+
if any(c.strip() for c in cells):
|
|
173
|
+
rows.append(" | ".join(cells))
|
|
174
|
+
if rows:
|
|
175
|
+
blocks.append(f"## 시트: {ws.title}\n" + "\n".join(rows))
|
|
176
|
+
return "\n\n".join(blocks)
|
|
177
|
+
finally:
|
|
178
|
+
wb.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _extract_docx(data: bytes) -> str:
|
|
182
|
+
"""DOCX → 문단 + 표 텍스트."""
|
|
183
|
+
from docx import Document
|
|
184
|
+
|
|
185
|
+
doc = Document(io.BytesIO(data))
|
|
186
|
+
parts: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
187
|
+
for table in doc.tables:
|
|
188
|
+
for row in table.rows:
|
|
189
|
+
line = " | ".join(c.text for c in row.cells)
|
|
190
|
+
if line.strip():
|
|
191
|
+
parts.append(line)
|
|
192
|
+
return "\n".join(parts)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _extract_pptx(data: bytes) -> str:
|
|
196
|
+
"""PPTX → 슬라이드별 텍스트(도형·표) + 발표자 노트."""
|
|
197
|
+
from pptx import Presentation
|
|
198
|
+
|
|
199
|
+
prs = Presentation(io.BytesIO(data))
|
|
200
|
+
parts: list[str] = []
|
|
201
|
+
for i, slide in enumerate(prs.slides, 1):
|
|
202
|
+
slide_lines: list[str] = []
|
|
203
|
+
for shape in slide.shapes:
|
|
204
|
+
if shape.has_text_frame and shape.text_frame.text.strip():
|
|
205
|
+
slide_lines.append(shape.text_frame.text.strip())
|
|
206
|
+
if shape.has_table:
|
|
207
|
+
for row in shape.table.rows:
|
|
208
|
+
cells = [c.text for c in row.cells]
|
|
209
|
+
if any(c.strip() for c in cells):
|
|
210
|
+
slide_lines.append(" | ".join(cells))
|
|
211
|
+
# 발표자 노트 (있으면)
|
|
212
|
+
if slide.has_notes_slide:
|
|
213
|
+
notes = slide.notes_slide.notes_text_frame.text
|
|
214
|
+
if notes and notes.strip():
|
|
215
|
+
slide_lines.append(f"(노트) {notes.strip()}")
|
|
216
|
+
if slide_lines:
|
|
217
|
+
parts.append(f"## 슬라이드 {i}\n" + "\n".join(slide_lines))
|
|
218
|
+
return "\n\n".join(parts)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ─── 디스패치 ──────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
# 텍스트 추출이 되는 포맷.
|
|
224
|
+
_EXTRACTORS = {
|
|
225
|
+
"hwp": _extract_hwp,
|
|
226
|
+
"hwpx": _extract_hwpx,
|
|
227
|
+
"pdf": _extract_pdf,
|
|
228
|
+
"xlsx": _extract_xlsx,
|
|
229
|
+
"docx": _extract_docx,
|
|
230
|
+
"pptx": _extract_pptx,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# 텍스트 본문이 없는 게 정상인 포맷 → 브리핑 빈칸 (사유 명시).
|
|
234
|
+
_NO_TEXT_KINDS = {
|
|
235
|
+
"jpg": "이미지 문서 (사진) — 텍스트 본문 없음",
|
|
236
|
+
"jpeg": "이미지 문서 (사진) — 텍스트 본문 없음",
|
|
237
|
+
"png": "이미지 문서 — 텍스트 본문 없음",
|
|
238
|
+
"gif": "이미지 문서 — 텍스트 본문 없음",
|
|
239
|
+
"tif": "이미지 문서 — 텍스트 본문 없음",
|
|
240
|
+
"tiff": "이미지 문서 — 텍스트 본문 없음",
|
|
241
|
+
"bmp": "이미지 문서 — 텍스트 본문 없음",
|
|
242
|
+
"heic": "이미지 문서 — 텍스트 본문 없음",
|
|
243
|
+
"dwg": "도면(CAD) — 텍스트 본문 없음",
|
|
244
|
+
"dxf": "도면(CAD) — 텍스트 본문 없음",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _ext_of(filename: str) -> str:
|
|
249
|
+
m = re.search(r"\.([A-Za-z0-9]+)\s*$", filename or "")
|
|
250
|
+
return m.group(1).lower() if m else ""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _count_chars(text: str) -> int:
|
|
254
|
+
return len(re.sub(r"\s+", "", text or ""))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def extract_text(data: bytes, filename: str) -> ExtractResult:
|
|
258
|
+
"""파일 바이트 + 파일명 → ExtractResult. 확장자로 포맷 판별."""
|
|
259
|
+
ext = _ext_of(filename)
|
|
260
|
+
|
|
261
|
+
if not data:
|
|
262
|
+
return ExtractResult(ok=False, text="", kind="unknown", char_count=0,
|
|
263
|
+
reason="빈 파일")
|
|
264
|
+
|
|
265
|
+
# 텍스트 본문이 없는 포맷 (사진·도면) — 추출 시도 없이 빈칸.
|
|
266
|
+
if ext in _NO_TEXT_KINDS:
|
|
267
|
+
return ExtractResult(ok=False, text="", kind=ext, char_count=0,
|
|
268
|
+
reason=_NO_TEXT_KINDS[ext])
|
|
269
|
+
|
|
270
|
+
extractor = _EXTRACTORS.get(ext)
|
|
271
|
+
if extractor is None:
|
|
272
|
+
# .hwp 3.x, .xls/.ppt(구버전), 기타 미지원 → 빈칸 (환각 대신 정직)
|
|
273
|
+
return ExtractResult(ok=False, text="", kind=ext or "unknown", char_count=0,
|
|
274
|
+
reason=f"지원하지 않는 형식(.{ext or '?'}) — PDF/HWP(5.x)/HWPX/XLSX/DOCX/PPTX 만 지원")
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
text = extractor(data) or ""
|
|
278
|
+
except Exception as e:
|
|
279
|
+
# DRM 걸린 hwp, 손상 파일 등 → 빈칸 (사유 기록)
|
|
280
|
+
return ExtractResult(ok=False, text="", kind=ext, char_count=0,
|
|
281
|
+
reason=f"본문 추출 실패: {str(e)[:160]}")
|
|
282
|
+
|
|
283
|
+
n = _count_chars(text)
|
|
284
|
+
if n < MIN_TEXT_CHARS:
|
|
285
|
+
return ExtractResult(
|
|
286
|
+
ok=False, text=text, kind=ext, char_count=n,
|
|
287
|
+
reason="본문 텍스트가 거의 없음 (스캔 이미지/빈 문서 가능성)",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return ExtractResult(ok=True, text=text, kind=ext, char_count=n, reason="")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
openai>=1.30
|
|
2
|
+
python-dotenv>=1.0
|
|
3
|
+
|
|
4
|
+
# HTTP API 서버
|
|
5
|
+
fastapi>=0.110
|
|
6
|
+
uvicorn[standard]>=0.27
|
|
7
|
+
python-multipart>=0.0.9
|
|
8
|
+
|
|
9
|
+
# 문서 텍스트 추출
|
|
10
|
+
olefile>=0.46 # .hwp (한글 5.x, OLE 복합문서) → 텍스트
|
|
11
|
+
PyMuPDF>=1.23 # .pdf → 텍스트 (import 명은 fitz)
|
|
12
|
+
openpyxl>=3.1 # .xlsx → 시트 텍스트
|
|
13
|
+
python-docx>=1.1 # .docx → 텍스트
|
|
14
|
+
python-pptx>=0.6 # .pptx → 슬라이드 텍스트 + 노트
|
|
15
|
+
# .hwpx 는 표준 라이브러리(zipfile + xml.etree)로 처리 — 추가 의존성 없음
|