文件版本:1.0 撰寫日期:2026-04-30 文件性質:SD — System Design(架構與實作策略) 上游 SA:
api-spec.md狀態:草稿,等使用者 review 後才進 frontend-spec.md設計守則:
- 不破壞既有
SspControlImplImportService(Excel 匯入)/ModuleFrameTemplateImportService- 不修改 jedi-oscal 套件(v1 範圍內),所有新邏輯在主專案
- 沿用既有 3 步驟 wizard /
validate_import/confirm_import切點 pattern- 嚴守 CLAUDE.md DDD 層級規範(Route 不查 DB / App Service 用
@transaction/ Repo session lazy)
api/oscal/routes/ssp/ ← Flask route (HTTP boundary)
└── ssp_docx_import_route.py
├── SspDocxImportParseRoute (POST /ssp-docx-imports/parse)
├── SspDocxImportRoute (GET / DELETE /ssp-docx-import/<uid>)
└── SspDocxImportConfirmRoute (POST /ssp-docx-import/<uid>/confirm)
app/oscal/service/ ← Application service (orchestration + @transaction)
└── ssp_docx_import_app_service.py
└── SspDocxImportAppService
├── upload_and_parse(file, framework, source_type, source_uid, mode, user)
├── get_parse_result(parse_uid, user)
├── confirm_import(parse_uid, decisions, manual_assignments, user)
└── discard_parse(parse_uid, user)
domain/oscal/ ← Domain service (pure business logic)
├── ssp_docx_parse_job_domain_service.py (CRUD on ssp_docx_parse_jobs)
└── (reuse jedi-oscal: ControlImplementationDomainService, ControlImplementationObjectiveDomainService)
domain/oscal/parser/ ← Pure parser (NEW)
├── docx_parser_core.py
│ └── DocxParserCore
│ ├── parse(file_path, framework, candidates) -> ParsedDocx
│ ├── _extract_structure(doc) -> DocxStructure
│ ├── _match_controls(structure, candidates) -> list[MatchedControl]
│ └── _score(paragraph, candidate) -> float
├── docx_intermediate.py (純 dataclasses: ParsedDocx, MatchedControl, UnmatchedParagraph)
└── framework_patterns.py (per-framework regex patterns)
domain/oscal/strategy/ ← Write strategy (NEW, Strategy pattern)
├── i_ssp_docx_write_strategy.py (interface)
├── module_frame_write_strategy.py (writes to module_frame_*_defaults + profile.include_controls)
└── ssp_write_strategy.py (writes to ssp_control_implementation + objective)
infra/oscal/model/ ← ORM model (NEW)
└── ssp_docx_parse_job.py
└── SspDocxParseJob (table: ssp_docx_parse_jobs)
infra/oscal/repository/ ← Repository impl (NEW)
└── ssp_docx_parse_job_repo_impl.py
└── SspDocxParseJobRepoImpl (extends BaseRepositoryImpl)
infra/oscal/mapper/ ← Entity ↔ Model mapper (NEW)
└── ssp_docx_parse_job_mapper.py
di_containers/oscal/ ← DI container (NEW sub-container)
└── ssp_docx_import_container.py
└── SspDocxImportContainer
(將 wire 進現有 OscalContainer)
common/util/ ← Shared utility (NEW)
└── control_id_matcher.py
├── parse_control_id(text, framework) -> Optional[str]
├── fuzzy_match_control(text, candidates) -> tuple[Optional[str], float]
└── detect_dominant_framework(structure) -> Optional[str]
common/code/ ← Error code (UPDATE existing)
└── grc_error_code.py
(新增 17 條 GRC_DOCX_* error code,見 SA §5)
只做:
禁止:DB 查詢、權限細節判斷、業務邏輯。
每個 public method 必須:
@transaction 裝飾jedi_common.handler.exception 標準 error classSspDocxParseJobDomainService:純 ORM CRUD,不知 HTTP / 不知檔案上傳DocxParserCore:純函式,輸入檔案路徑 + framework + 候選清單,輸出中介結構(不接 DB、不接 HTTP)ImportResultSspDocxParseJob model:SQLAlchemy declarative,定 column type / RLSSspDocxParseJobRepoImpl:繼承 BaseRepositoryImpl[Entity, Query, Model, Mapper],session 自動 lazyINPUT:
- file_path: str
- framework: str(cmmc-l1 / nist-800-171 / iso27001 / ...)
- candidates: list[CandidateControl]
(control_id, control_name, objective_keys, current_implementation_description, current_objectives_descriptions)
ALGO:
1. doc = python-docx.Document(file_path)
2. 抽取結構 _extract_structure(doc):
- 走訪 doc.paragraphs,記錄每個 paragraph:
- text (純文字,rich text 已被 .text 取為 plain)
- style.name("Heading 1" / "Heading 2" / "Heading 3" / "Normal" / ...)
- paragraph_idx
- context: { preceding_h1, preceding_h2, preceding_h3 } ← running stack
- 走訪 doc.tables,記錄每個 table:
- rows[][] (cell text)
- 緊鄰前面的 paragraph_idx
- 輸出 DocxStructure { paragraphs, tables }
3. 偵測 framework 一致性 detect_dominant_framework(structure):
- 對所有 H3 paragraph.text 套 control_id_pattern (per framework)
- 計算命中比例
- 若 < 60% 命中、且有其他 framework pattern 命中 > 60% → return mismatch_to_<framework>
- 若 < 30% 命中任何 framework → return None (FATAL: no_control_id_found)
4. matched_controls, unmatched_paragraphs = _match_controls(structure, candidates):
For each H3 paragraph:
a. control_id = parse_control_id(text, framework)
b. 若命中 → score = 0.95(H3 + ID 直接命中),matched_controls.add({control_id, content_paragraphs, score})
- content_paragraphs = 接下來到下一個 H3 / H1 / H2 之前的所有 normal paragraphs
- tables_in_section = 緊鄰的 tables,第二欄當 objective description
c. 若未命中 → 該段視為 unmatched_paragraph
For each Normal paragraph 不在已命中 H3 範圍內:
a. 嘗試在段落文字內 parse_control_id → 若有 → matched (score = 0.6)
b. 否則 fuzzy_match_control(text, candidates) → (best_id, score)
c. 若 score < 0.4 → unmatched_paragraphs.add(paragraph)
5. 找出 missing_baseline_controls:
baseline_ids = {c.control_id for c in candidates}
matched_ids = {m.control_id for m in matched_controls}
missing = baseline_ids - matched_ids
6. 計算 warning_level:
match_rate = len(matched_controls) / len(structure.controls_seen) ← (僅統計 docx 裡看到的)
missing_rate = len(missing) / len(candidates)
if match_rate < 0.5 or missing_rate > 0.3:
warning_level = 'partial'
7. 組裝 ParsedDocx:
ParsedDocx(
summary,
matched_controls (含 score + parsed_implementation_description + objectives),
unmatched_paragraphs,
missing_baseline_controls,
predicted_module_frame_controls (僅 mode=full)
)
RETURN ParsedDocx
| 信號 | 分數 |
|---|---|
| H3 標題完全包含 control_id(regex 命中) | 0.95 |
| H3 標題包含 control 名稱(不含 ID) | 0.65 |
| Normal paragraph 內含 control_id | 0.60 |
| Normal paragraph 文字與 control_name 相似度 > 0.7(rapidfuzz token_set_ratio) | 0.40-0.55(線性映射) |
| 上方 heading proximity(前 3 段內有 H3 命中相同 control) | + 0.20 |
| 表格 row first cell 含 (a)(b)(c) 模式 + 上方 H3 命中 | 0.85(歸到 objective) |
最終 score 取最大信號,不疊加(避免破表)。
實作位置:common/util/control_id_matcher.py:fuzzy_match_control()
domain/oscal/parser/framework_patterns.py:
FRAMEWORK_PATTERNS = {
'cmmc-l1': re.compile(r'\b([A-Z]{2})\.L1-b\.\d+\.[ivxlcdm]+\b', re.I),
'cmmc-l2': re.compile(r'\b([A-Z]{2})\.L2-\d+\.\d+\.\d+\b', re.I),
'nist-800-171': re.compile(r'\b3\.\d+\.\d+\b'),
'iso27001': re.compile(r'\bA\.\d+(\.\d+)*\b'),
}設計守則:v1A 不引入 background job,所有過期判斷在 read path 完成。
# In SspDocxImportAppService.get_parse_result(parse_uid, user):
job = self._job_domain.get_one(uid=parse_uid)
if job.status == 'awaiting_review':
expires_at = job.created_at + timedelta(hours=1)
if datetime.utcnow() > expires_at:
self._job_domain.update_status(parse_uid, 'expired')
# MinIO 檔案不立即刪除(避免 race condition),由日後背景清理 job 處理(v2)
raise PreconditionFailedError(GrcErrorCode.GRC_DOCX_PARSE_JOB_EXPIRED)
# Same check in confirm_import().取捨:
| 現有值 | docx 值 | default_action |
|---|---|---|
| 空 | 有 | use_docx |
| 有 | 空 | keep_current |
| 相同 | 相同 | 不顯示(client 收到的 list 不含此項,不算 conflict) |
| 不同 | 不同 | keep_current(保守) |
實作:在 ParsedDocx 組裝時,已將 current_* 欄位 fetch 並計算好 default_action,FE 直接顯示。
接收 confirm 階段的 payload:
manual_assignments = [
{
paragraph_idx: 142,
targets: [
{ control_id: 'IR.L2-x', level: 'implementation', merge_action: 'append' },
{ control_id: 'AC.L1-b.1.i', level: 'objective', objective_key: 'c', merge_action: 'replace' }
]
}
]處理邏輯(在 WriteStrategy 內):
implementation:找到目標 control_id 的 implementation_description,依 merge_action 處理objective:找到 (control_id, objective_key) 對應 row,同上merge_action:
append:current_value + '\n\n' + paragraph.textreplace:paragraph.text僅入口 A1(合規資源庫 Step 2):
predicted_module_frame_controlspredicted_controls_user_selectionoscal_profiles.include_controls = predicted_controls_user_selectionmodule_frame_control_defaults row決議:
if dominant_pattern == None:
raise FATAL("GRC_DOCX_NO_CONTROL_ID_FOUND")
elif dominant_pattern != user_selected_framework:
if dominant_match_rate >= 0.6 AND user_match_rate < 0.3:
raise FATAL("GRC_DOCX_FRAMEWORK_MISMATCH")
else:
# 兩者比例接近,視為 partial warning
warning_level = 'partial'
warnings.append("docx 主要 framework 不明,建議確認選擇")
threshold 在 framework_patterns.py 設常數,未來可調整。
# domain/oscal/strategy/i_ssp_docx_write_strategy.py
from abc import ABC, abstractmethod
class IWriteStrategy(ABC):
@abstractmethod
def write(
self,
parsed: ParsedDocx,
decisions: ImportDecisions, # decisions + manual_assignments + skipped_idxs + predicted_user_selection
source_uid: str,
user_id: str,
) -> ImportResult: ...ImportDecisions 是 dataclass,無業務邏輯。
職責:
module_frame_control_defaults (lazy-create per control_id) + module_frame_control_objective_defaultsoscal_profiles.include_controls依賴注入:
ModuleFrameDomainService (jedi-something)ModuleFrameControlDefaultDomainServiceModuleFrameControlObjectiveDefaultDomainServiceProfileDomainService (jedi-oscal)關鍵:lazy-create。先 query control_default by (module_frame_id, control_id),找不到就 create,找到就 update。
職責:
ssp_control_implementation (jedi-oscal ControlImplementationDomainService)ssp_control_implementation_objective (jedi-oscal ObjectiveDomainService)information_systems(後述 §6.1 upsert)依賴注入:
ControlImplementationDomainService (jedi-oscal)ControlImplementationObjectiveDomainService (jedi-oscal)InformationSystemService (jedi_information_system)ProjectInformationSystemRepoImplSspDocxImportAppService.confirm_import() 內:
strategy = self._module_frame_strategy if job.source_type == 'module_frame' \
else self._ssp_strategy
result = strategy.write(parsed=job.parsed_result, decisions=decisions, ...)兩個 strategy 在 DI container 同時 wire,runtime 選用。
ssp_docx_parse_jobs-- Date: 2026-04-30
CREATE TABLE oscal.ssp_docx_parse_jobs (
id SERIAL PRIMARY KEY,
uid VARCHAR(36) NOT NULL UNIQUE, -- UUIDv4
tenant_id INTEGER NOT NULL, -- RLS
source_type VARCHAR(20) NOT NULL, -- 'module_frame' | 'project_ssp'
source_uid VARCHAR(36) NOT NULL, -- module_frame.uid or ssp.uid
mode VARCHAR(20) NOT NULL, -- 'full' | 'statement_only'
framework VARCHAR(40) NOT NULL, -- 'cmmc-l1' | ...
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|parsing|awaiting_review|completed|failed|expired|discarded
file_path VARCHAR(255), -- MinIO path
file_name VARCHAR(255), -- 原始檔名
file_size BIGINT,
parsed_result JSONB, -- 中介結構
error_code VARCHAR(40), -- 失敗時的 GrcErrorCode
error_message TEXT,
import_summary JSONB, -- confirm 後寫入
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 軟刪除(discard / 清理時)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_user VARCHAR(50) NOT NULL, -- login_name
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_user VARCHAR(50) NOT NULL
);
CREATE INDEX idx_ssp_docx_parse_jobs_source ON oscal.ssp_docx_parse_jobs(source_type, source_uid);
CREATE INDEX idx_ssp_docx_parse_jobs_status ON oscal.ssp_docx_parse_jobs(status, created_at);
CREATE INDEX idx_ssp_docx_parse_jobs_tenant ON oscal.ssp_docx_parse_jobs(tenant_id);
ALTER TABLE oscal.ssp_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ssp_docx_parse_jobs ON oscal.ssp_docx_parse_jobs
USING (tenant_id::text = current_setting('app.user_id', true)
OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%');
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ssp_docx_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ssp_docx_parse_jobs_id_seq TO cm_app;status 狀態機:
pending → parsing → awaiting_review → completed
↘ ↘
failed expired (1hr 未確認)
discarded (使用者主動取消)
parsed_result JSONB 結構{
"summary": { ... },
"matched_controls": [ {control_id, score, parsed_implementation_description, objectives:[...], current_*} ],
"unmatched_paragraphs": [ {paragraph_idx, text, context, rule_score, rule_guess_control_id} ],
"missing_baseline_controls": [...],
"predicted_module_frame_controls": [...],
"skipped_paragraphs": [85, 142, 200],
"warnings": [...]
}JSONB 欄位不需 schema migration,未來擴充欄位向下相容。
ssp_control_implementation / ssp_control_implementation_objective (jedi-oscal): 只用既有 add / update API,不加欄位module_frame_control_defaults / module_frame_control_objective_defaults: 只用既有 serviceinformation_systems: 只用 service,新增一個 upsert_by_name helper method(見 §6.1)oscal_profiles.include_controls: 用既有 ProfileDomainService.update_include_controls()# di_containers/oscal/ssp_docx_import_container.py
from dependency_injector import containers, providers
class SspDocxImportContainer(containers.DeclarativeContainer):
config = providers.Configuration()
# Repos / Mappers
parse_job_mapper = providers.Singleton(SspDocxParseJobMapper)
parse_job_repo = providers.Singleton(SspDocxParseJobRepoImpl, mapper=parse_job_mapper)
# Domain services
parse_job_domain_service = providers.Factory(
SspDocxParseJobDomainService, repo=parse_job_repo
)
# Parser (純函式 class,singleton)
docx_parser_core = providers.Singleton(DocxParserCore)
control_id_matcher = providers.Singleton(ControlIdMatcher)
# Strategies (注入既有 OscalContainer + jedi packages 的 service)
module_frame_write_strategy = providers.Factory(
ModuleFrameWriteStrategy,
module_frame_control_default_service=Provide['module_frame_container.control_default_service'],
module_frame_control_objective_default_service=Provide['module_frame_container.control_objective_default_service'],
profile_domain_service=Provide['oscal_container.profile_domain_service'],
)
ssp_write_strategy = providers.Factory(
SspWriteStrategy,
control_impl_service=Provide['oscal_container.control_implementation_domain_service'],
objective_service=Provide['oscal_container.control_implementation_objective_domain_service'],
information_system_service=Provide['information_system_container.information_system_service'],
project_info_system_repo=Provide['information_system_container.project_information_system_repo'],
)
# App service
ssp_docx_import_app_service = providers.Factory(
SspDocxImportAppService,
parse_job_domain=parse_job_domain_service,
parser=docx_parser_core,
matcher=control_id_matcher,
module_frame_write_strategy=module_frame_write_strategy,
ssp_write_strategy=ssp_write_strategy,
# 用於拿候選控制項
profile_domain_service=Provide['oscal_container.profile_domain_service'],
catalog_domain_service=Provide['oscal_container.catalog_domain_service'],
# 權限驗證用
project_participant_domain_service=Provide['participant_container.project_participant_domain_service'],
module_frame_domain_service=Provide['module_frame_container.module_frame_domain_service'],
)di_containers/oscal/oscal_containers.py 新增:
ssp_docx_import = providers.Container(SspDocxImportContainer)依 CLAUDE.md,新增的 _router.py / _route.py / _handler.py 會被 config/di_modules.py 自動掃進來。ssp_docx_import_route.py 會被 wire。Route 需要 @inject 拿 SspDocxImportAppService。
upsert_by_name不修改既有
add/update簽名,只加新 method。
# jedi_information_system/app/service/information_system_service.py
class InformationSystemService:
@transaction
def upsert_by_name(
self,
name: str,
tenant_id: int,
fields: dict,
user_id: str,
) -> InformationSystemEntity:
existing = self._repo.get_one(
InformationSystemQueryEntity(name=name, tenant_id=tenant_id)
)
if existing:
return self._repo.update(existing.id, fields, user_id)
else:
return self._repo.add(InformationSystemEntity(name=name, tenant_id=tenant_id, **fields))呼叫者:SspWriteStrategy.write() 處理 SSP metadata。
SA OPEN-1 解答:v1 用「name + tenant_id 唯一鍵 → 找到就 update、找不到就 create」,不額外讓使用者選 merge / 新建。同名衝突視為「同一個 information_system 的不同 docx 版本」。
v1 簡化策略:
ssp_control_implementation (system_security_plan_id, control_identifier) 應已 unique(jedi-oscal entity)INSERT ... ON CONFLICT DO UPDATE 或 get → update / add 兩 stepversion column + optimistic lockUI 層提示:「有他人正在編輯此 SSP,您的修改可能被覆蓋」(可選,留 frontend-spec 決定)。
完全獨立,無共用 service / repo。差別:
| 項目 | Excel Import | Docx Import (新) |
|---|---|---|
| 入口 Route | ssp_control_impl_import_route.py |
ssp_docx_import_route.py |
| App Service | SspControlImplImportService |
SspDocxImportAppService |
| 解析 | openpyxl 直讀 | python-docx + DocxParserCore |
| 步驟拆分 | validate / confirm 2 step | parse / preview / confirm 3 step(多了預覽編輯) |
| Job 表 | 無(純 stateless) | ssp_docx_parse_jobs(短期 stateful) |
兩個服務同時存在,使用者選 Excel 或 docx 由 FE wizard 決定。
def is_heading(p: docx.text.Paragraph) -> Optional[int]:
"""回 1/2/3 表示 H1/H2/H3,None 表示非 heading"""
style_name = p.style.name # e.g., "Heading 1"
match = re.match(r'^Heading (\d)$', style_name)
if match:
level = int(match.group(1))
return level if 1 <= level <= 6 else None
if style_name == 'Title':
return 1
return Nonedef extract_objective_rows(table: docx.table.Table) -> list[tuple[str, str]]:
"""回 [(objective_key_text, description), ...]"""
out = []
for row in table.rows:
if len(row.cells) < 2:
continue
first = row.cells[0].text.strip()
second = row.cells[1].text.strip()
# objective key 通常以 (a) (b) (c) 開頭
m = re.match(r'^\s*\(([a-z])\)', first)
if m:
out.append((m.group(1), second))
return outparagraph.text 已是 plain text(python-docx 會展平 runs),但 list / 表格需要手動處理:
run 用 ''.join 串接(已是 default 行為)style_name 偵測為 List Bullet / List Number 時,可選擇加 - 前綴(v1 不做,純取 text)python-docx 不直接告訴你「這個 table 在哪個 paragraph 後面」。解法:
def iter_block_items(parent):
"""yield Paragraph or Table in document order"""
parent_elm = parent.element.body
for child in parent_elm.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield Table(child, parent)走訪時維持 current_h3_control_id,碰到 table 時歸屬到該 control。
null(不寫入 DB,等於 keep_current)class ParseException(Exception):
"""FATAL — 整份匯入終止"""
pass
def parse_or_raise(...):
try:
doc = Document(file_path)
except Exception as e:
raise ParseException(GrcErrorCode.GRC_DOCX_PARSE_FAILED, e)
structure = _extract_structure(doc)
if not structure.has_any_heading():
raise ParseException(GrcErrorCode.GRC_DOCX_NO_CONTROL_ID_FOUND)
dominant_framework = detect_dominant_framework(structure)
if dominant_framework is None:
raise ParseException(GrcErrorCode.GRC_DOCX_NO_CONTROL_ID_FOUND)
if dominant_framework != user_framework and dominant_match_rate > 0.6:
raise ParseException(GrcErrorCode.GRC_DOCX_FRAMEWORK_MISMATCH)
...App service 接 ParseException:寫入 job.status='failed' + error_code + error_message,回 422 給 FE。
不 raise,只回 ParsedDocx with warning_level='partial'。FE Step 1 上方顯示 banner。
不 raise,加進 unmatched_paragraphs。FE Step 1 顯示拖拉 UI。
| AC | SD 設計位置 |
|---|---|
| AC-1 ~ AC-3(三入口按鈕) | §1 Route + §3 strategy 選擇 + frontend-spec.md |
| AC-4(必選 framework) | Route 層 marshmallow schema 必填 |
| AC-5(解析時間 < 10s) | §2.1 純規則無 LLM,python-docx 5MB 預估 < 5s |
| AC-6(規則命中自動配對) | §2.1 + §2.2 |
| AC-7(拖拉到主述/objective) | §2.6 |
| AC-8(同段拖到多控制項) | §2.6(targets[] 是陣列) |
| AC-9(拖到已有內容 conflict) | §2.6 merge_action |
| AC-10(diff preview) | §2.5 |
| AC-11(每筆 objective 獨立行) | §3.x ParsedDocx 結構 + §2.5 |
| AC-12(批次操作) | FE only — frontend-spec.md |
| AC-13(baseline 缺漏不阻擋) | §2.1 step 5 + §3.x ImportResult.missing_left_blank |
| AC-14(framework mismatch FATAL) | §2.8 |
| AC-15(無 control_id FATAL) | §8.1 |
| AC-16(PARTIAL warning) | §8.2 |
| AC-17(1hr expire) | §2.4 lazy expiration |
| AC-18(skipped_paragraphs 紀錄) | §4.2 JSONB 欄位 |
| AC-19(rich text → plain) | §7.3 |
| AC-20(圖片忽略) | §2.1 不走訪 inline_shape |
| AC-21(objective 第 2 欄當 description) | §7.2 |
| AC-22(權限) | §1.2 App Service 第一行檢查 |
| AC-23(information_systems 寫入) | §6.1 upsert_by_name |
test/)| 測試對象 | 測試類別 |
|---|---|
DocxParserCore.parse() |
多份 minimal docx fixture,覆蓋各 happy / FATAL / PARTIAL 情境 |
ControlIdMatcher.parse_control_id() / fuzzy_match_control() |
純函式,輸入文字輸出 (id, score) |
ModuleFrameWriteStrategy.write() |
mock domain services,驗證 lazy-create 行為 |
SspWriteStrategy.write() |
同上 |
SspDocxImportAppService 各 method |
整合層,含權限檢查 / @transaction 測試 |
raw-requirement/reference/ASIA-CMMC-SSP-DRAFT-202604.docx(5MB 真實客戶範本)| OPEN | 解法 |
|---|---|
| OPEN-1 information_systems 同名碰撞 | §6.1 upsert_by_name |
| OPEN-2 多人 race condition | §6.2 v1 不加 lock,後者勝 |
| OPEN-3 framework mismatch threshold | §2.8 dominant_match_rate 0.6, user_match_rate < 0.3 |
| OPEN-4 mode=full 空清單 | 視為「使用者明確選擇 0 個控制項」,允許但 Step 2 顯示 warning |
| OPEN-5 objective_key 驗證 | §2.6 在 strategy 層驗證 |
| OPEN-6 1hr expire 是否合適 | 採用 lazy + 1hr 預設,可後續從 system_config 調 |
| ID | 問題 | 影響 |
|---|---|---|
| SD-OPEN-1 | mode=full 移除控制項時,舊的 module_frame_control_defaults 是否要刪除?目前設計保留(避免誤刪),但會出現「孤兒」defaults |
低;可在 v2 加清理 job |
| SD-OPEN-2 | dominant_match_rate / user_match_rate threshold 參數化儲存位置:常數 vs system_config |
中;建議常數,未來真要客製再升級 |
| SD-OPEN-3 | LLM hook 介面預留位置:parser core 是否預留 _llm_classify(paragraph) -> control_id method 簽名?v1 不實作但留 abstract base 讓 v2 加 |
中;建議不預留,v2 來時再加(YAGNI) |
| SD-OPEN-4 | python-docx 對舊版 .doc 檔不支援。route 層只擋副檔名 .docx,但若使用者傳 .docx 但實際是 .doc 會失敗 |
低;用既有 mime 偵測抓 |
| SD-OPEN-5 | parse_job 的 file_path:MinIO 內存還是 local tmp?若同步處理完不再用,是否一律刪除? | 中;建議 confirm 後刪除 file,但保留 row(審計) |
_score 公式information_systems.upsert_by_name 大量被觸發若 docx 標題隨意,會多出大量無意義 row → 加「最少 metadata 才寫入」門檻(如 name 至少 5 字)parsed_result 在 5MB docx 解析後可能膨脹(包含 273 段 + 25 控制項全文)→ 預估 < 1MB 安全;若實測超過,可改存 MinIO 帶 file_pathinfra/oscal/model/ssp_docx_parse_job.pyinfra/oscal/repository/ssp_docx_parse_job_repo_impl.pyinfra/oscal/mapper/ssp_docx_parse_job_mapper.pydomain/oscal/entity/ssp_docx_parse_job_entity.pydomain/oscal/repository/i_ssp_docx_parse_job_repo.pydomain/oscal/services/ssp_docx_parse_job_domain_service.pydomain/oscal/parser/docx_parser_core.pydomain/oscal/parser/docx_intermediate.pydomain/oscal/parser/framework_patterns.pydomain/oscal/strategy/i_ssp_docx_write_strategy.pydomain/oscal/strategy/module_frame_write_strategy.pydomain/oscal/strategy/ssp_write_strategy.pyapp/oscal/service/ssp_docx_import_app_service.pyapi/oscal/routes/ssp/ssp_docx_import_route.pyapi/oscal/serializers/ssp/ssp_docx_import.py (marshmallow schemas)di_containers/oscal/ssp_docx_import_container.pycommon/util/control_id_matcher.pyscripts/sql/ssp_docx_parse_jobs_migration.sqlcommon/code/grc_error_code.py (新增 17 個 error code)di_containers/oscal/oscal_containers.py (註冊新 sub-container)jedi_information_system/app/service/information_system_service.py (新增 upsert_by_name)api/oscal/__init__.py (註冊新 route)components/grc/SspDocxImportDialog.vuecomponents/grc/ssp-docx-import/StepUpload.vuecomponents/grc/ssp-docx-import/StepPreview.vuecomponents/grc/ssp-docx-import/UnmatchedParagraphList.vue(含拖拉)components/grc/ssp-docx-import/MatchedControlDiff.vuecomponents/grc/ssp-docx-import/ConflictModal.vueservice/SspDocxImportService.jscomponents/grc/ModuleFrame.vue (Step 2 / Step 3 加按鈕)Sign-off 區(Phase 2.2 結束時填寫)
| 角色 | 名字 | 日期 | 備註 |
|---|---|---|---|
| Tech Lead |
簽完後進 Phase 2.3(frontend-spec.md 撰寫)。