完善skills;测试用例生成页面功能初步实现

This commit is contained in:
2026-05-05 19:45:33 +08:00
parent 0c2ed67e2a
commit 69b49d28b2
35 changed files with 4396 additions and 658 deletions

View File

@@ -0,0 +1,34 @@
"""add interface fields to srs requirements
Revision ID: b7217f0c3d92
Revises: a4f9c89b7d11
Create Date: 2026-04-18 19:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b7217f0c3d92"
down_revision: Union[str, None] = "a4f9c89b7d11"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("srs_requirements", sa.Column("section_uid", sa.String(length=64), nullable=True))
op.add_column("srs_requirements", sa.Column("interface_name", sa.String(length=255), nullable=True))
op.add_column("srs_requirements", sa.Column("interface_type", sa.String(length=128), nullable=True))
op.add_column("srs_requirements", sa.Column("data_source", sa.String(length=255), nullable=True))
op.add_column("srs_requirements", sa.Column("data_destination", sa.String(length=255), nullable=True))
def downgrade() -> None:
op.drop_column("srs_requirements", "data_destination")
op.drop_column("srs_requirements", "data_source")
op.drop_column("srs_requirements", "interface_type")
op.drop_column("srs_requirements", "interface_name")
op.drop_column("srs_requirements", "section_uid")

View File

@@ -0,0 +1,59 @@
"""add testing generation history table
Revision ID: c9f6e7a1bd34
Revises: b7217f0c3d92
Create Date: 2026-04-26 20:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c9f6e7a1bd34"
down_revision: Union[str, None] = "b7217f0c3d92"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"testing_generations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("job_id", sa.Integer(), nullable=False),
sa.Column("source_job_id", sa.Integer(), nullable=True),
sa.Column("source_document_name", sa.String(length=255), nullable=False),
sa.Column("generated_at", sa.DateTime(), nullable=False),
sa.Column("total_requirements", sa.Integer(), nullable=False, server_default="0"),
sa.Column("knowledge_base_id", sa.Integer(), nullable=True),
sa.Column("generated_file", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["job_id"], ["tool_jobs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["knowledge_base_id"], ["knowledge_bases.id"]),
sa.ForeignKeyConstraint(["source_job_id"], ["tool_jobs.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("job_id"),
)
op.create_index(op.f("ix_testing_generations_id"), "testing_generations", ["id"], unique=False)
op.create_index(
op.f("ix_testing_generations_source_job_id"),
"testing_generations",
["source_job_id"],
unique=False,
)
op.create_index(
op.f("ix_testing_generations_knowledge_base_id"),
"testing_generations",
["knowledge_base_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_testing_generations_knowledge_base_id"), table_name="testing_generations")
op.drop_index(op.f("ix_testing_generations_source_job_id"), table_name="testing_generations")
op.drop_index(op.f("ix_testing_generations_id"), table_name="testing_generations")
op.drop_table("testing_generations")

View File

@@ -1,6 +1,8 @@
import logging
import asyncio
from typing import Any, Dict, List
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.config import settings
@@ -15,6 +17,8 @@ from app.services.testing_pipeline import run_testing_pipeline
from app.services.vector_store import VectorStoreFactory
router = APIRouter()
logger = logging.getLogger(__name__)
MODEL_PIPELINE_TIMEOUT_SECONDS = 300
async def _build_kb_vector_stores(db: Session, knowledge_bases: List[KnowledgeBase]) -> List[Dict[str, Any]]:
@@ -47,38 +51,75 @@ async def generate_testing_content(
knowledge_context = (payload.knowledge_context or "").strip()
if payload.knowledge_base_ids:
knowledge_bases = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id.in_(payload.knowledge_base_ids),
KnowledgeBase.user_id == current_user.id,
try:
knowledge_bases = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id.in_(payload.knowledge_base_ids),
KnowledgeBase.user_id == current_user.id,
)
.all()
)
.all()
kb_vector_stores = await _build_kb_vector_stores(db, knowledge_bases)
if kb_vector_stores:
retriever = MultiKBRetriever(
reranker_weight=settings.RERANKER_WEIGHT,
)
retrieval_rows = await retriever.retrieve(
query=payload.requirement_text,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=max(12, payload.retrieval_top_k * 2),
top_k=payload.retrieval_top_k,
)
if retrieval_rows:
knowledge_context = format_retrieval_context(retrieval_rows)
except Exception as exc:
logger.exception(
"Testing generation retrieval fallback triggered for user=%s knowledge_base_ids=%s: %s",
current_user.id,
payload.knowledge_base_ids,
exc,
)
pipeline_kwargs = {
"user_requirement_text": payload.requirement_text,
"requirement_type_input": payload.requirement_type,
"debug": payload.debug,
"knowledge_context": knowledge_context,
"use_model_generation": payload.use_model_generation,
"max_items_per_group": payload.max_items_per_group,
"cases_per_item": payload.cases_per_item,
"max_focus_points": payload.max_focus_points,
"max_llm_calls": payload.max_llm_calls,
}
try:
result = await asyncio.wait_for(
asyncio.to_thread(run_testing_pipeline, **pipeline_kwargs),
timeout=MODEL_PIPELINE_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError as exc:
logger.exception(
"Testing pipeline timed out for user=%s use_model_generation=%s after %s seconds",
current_user.id,
payload.use_model_generation,
MODEL_PIPELINE_TIMEOUT_SECONDS,
)
raise HTTPException(
status_code=504,
detail=f"LLM generation timed out after {MODEL_PIPELINE_TIMEOUT_SECONDS} seconds",
) from exc
except Exception as exc:
logger.exception(
"Testing pipeline failed for user=%s use_model_generation=%s: %s",
current_user.id,
payload.use_model_generation,
exc,
)
raise HTTPException(
status_code=500,
detail=f"LLM generation failed: {exc}",
) from exc
kb_vector_stores = await _build_kb_vector_stores(db, knowledge_bases)
if kb_vector_stores:
retriever = MultiKBRetriever(
reranker_weight=settings.RERANKER_WEIGHT,
)
retrieval_rows = await retriever.retrieve(
query=payload.requirement_text,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=max(12, payload.retrieval_top_k * 2),
top_k=payload.retrieval_top_k,
)
if retrieval_rows:
knowledge_context = format_retrieval_context(retrieval_rows)
result = run_testing_pipeline(
user_requirement_text=payload.requirement_text,
requirement_type_input=payload.requirement_type,
debug=payload.debug,
knowledge_context=knowledge_context,
use_model_generation=payload.use_model_generation,
max_items_per_group=payload.max_items_per_group,
cases_per_item=payload.cases_per_item,
max_focus_points=payload.max_focus_points,
max_llm_calls=payload.max_llm_calls,
)
return result

View File

@@ -1,26 +1,46 @@
from pathlib import Path
from typing import Any, List
import shutil
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.tooling import SRSExtraction, ToolJob
from app.models.knowledge import KnowledgeBase
from app.models.tooling import SRSExtraction, TestingGeneration, ToolJob
from app.models.user import User
from app.schemas.tooling import (
SRSToolCreateJobResponse,
SRSToolHistoryItem,
SRSToolJobStatusResponse,
SRSToolRequirementsSaveRequest,
SRSToolResultResponse,
TestingGenerationCreateRequest,
TestingGenerationCreateResponse,
TestingGenerationHistoryItem,
TestingGenerationJobStatusResponse,
TestingGenerationResultResponse,
TestingGenerationSaveRequest,
ToolDefinitionResponse,
)
from app.services.srs_job_service import (
build_srs_upload_path,
build_result_response,
delete_srs_job,
ensure_upload_path,
list_srs_history,
replace_requirements,
run_srs_job,
)
from app.services.testing_generation_service import (
build_testing_generation_response,
create_testing_generation,
delete_testing_generation,
list_testing_history,
)
from app.services.testing_generation_job_service import run_testing_generation_job
from app.tools.registry import ToolRegistry
from app.tools.srs_reqs_qwen import get_srs_tool
@@ -173,3 +193,223 @@ async def save_srs_requirements(
db.refresh(extraction)
return build_result_response(job, extraction)
@router.get("/srs/history", response_model=List[SRSToolHistoryItem])
async def get_srs_history(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
return list_srs_history(db, current_user.id)
@router.delete("/srs/jobs/{job_id}")
async def delete_srs_job_api(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
job = (
db.query(ToolJob)
.filter(ToolJob.id == job_id, ToolJob.user_id == current_user.id)
.first()
)
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
upload_path = build_srs_upload_path(job_id)
delete_srs_job(db, job)
if upload_path.exists():
shutil.rmtree(upload_path, ignore_errors=True)
return {"message": "删除成功"}
@router.post("/testing/generations", response_model=TestingGenerationResultResponse)
async def save_testing_generation(
payload: TestingGenerationSaveRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
if payload.source_job_id is not None:
source_job = (
db.query(ToolJob)
.filter(
ToolJob.id == payload.source_job_id,
ToolJob.user_id == current_user.id,
)
.first()
)
if not source_job:
raise HTTPException(status_code=404, detail="来源文件不存在")
if payload.knowledge_base_id is not None:
knowledge_base = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id == payload.knowledge_base_id,
KnowledgeBase.user_id == current_user.id,
)
.first()
)
if not knowledge_base:
raise HTTPException(status_code=404, detail="知识库不存在")
return create_testing_generation(db, current_user.id, payload)
@router.post("/testing/jobs", response_model=TestingGenerationCreateResponse)
async def create_testing_generation_job(
background_tasks: BackgroundTasks,
payload: TestingGenerationCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
if payload.source_job_id is not None:
source_job = (
db.query(ToolJob)
.filter(
ToolJob.id == payload.source_job_id,
ToolJob.user_id == current_user.id,
)
.first()
)
if not source_job:
raise HTTPException(status_code=404, detail="来源文件不存在")
if payload.knowledge_base_id is not None:
knowledge_base = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id == payload.knowledge_base_id,
KnowledgeBase.user_id == current_user.id,
)
.first()
)
if not knowledge_base:
raise HTTPException(status_code=404, detail="知识库不存在")
job = ToolJob(
user_id=current_user.id,
tool_name="testing.case_generator",
status="pending",
input_file_name=payload.source_document_name,
input_file_path="",
output_summary={
"source_document_name": payload.source_document_name,
"current_step": 0,
"total_steps": len(payload.requirements),
},
)
db.add(job)
db.commit()
db.refresh(job)
background_tasks.add_task(
run_testing_generation_job,
job.id,
payload.dict(),
)
return {
"job_id": job.id,
"status": job.status,
}
@router.get("/testing/jobs/{job_id}", response_model=TestingGenerationJobStatusResponse)
async def get_testing_generation_job_status(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
job = (
db.query(ToolJob)
.filter(
ToolJob.id == job_id,
ToolJob.user_id == current_user.id,
)
.first()
)
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
summary = job.output_summary or {}
return {
"job_id": job.id,
"tool_name": job.tool_name,
"status": job.status,
"error_message": job.error_message,
"started_at": job.started_at,
"completed_at": job.completed_at,
"source_document_name": summary.get("source_document_name"),
"current_step": summary.get("current_step"),
"total_steps": summary.get("total_steps"),
"current_requirement_id": summary.get("current_requirement_id"),
}
@router.get("/testing/history", response_model=List[TestingGenerationHistoryItem])
async def get_testing_history(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
return list_testing_history(db, current_user.id)
@router.get("/testing/jobs/{job_id}/result", response_model=TestingGenerationResultResponse)
async def get_testing_generation_result(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
job = (
db.query(ToolJob)
.filter(
ToolJob.id == job_id,
ToolJob.user_id == current_user.id,
)
.first()
)
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
generation = (
db.query(TestingGeneration)
.filter(TestingGeneration.job_id == job.id)
.first()
)
if not generation:
raise HTTPException(status_code=404, detail="任务结果不存在")
return build_testing_generation_response(job, generation)
@router.delete("/testing/jobs/{job_id}")
async def delete_testing_generation_api(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
job = (
db.query(ToolJob)
.filter(
ToolJob.id == job_id,
ToolJob.user_id == current_user.id,
)
.first()
)
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
generation = (
db.query(TestingGeneration)
.filter(TestingGeneration.job_id == job.id)
.first()
)
if not generation:
raise HTTPException(status_code=404, detail="任务结果不存在")
delete_testing_generation(db, job)
return {"message": "删除成功"}

View File

@@ -2,7 +2,7 @@ from .user import User
from .knowledge import KnowledgeBase, Document, DocumentChunk
from .chat import Chat, Message
from .api_key import APIKey
from .tooling import ToolJob, SRSExtraction, SRSRequirement
from .tooling import ToolJob, SRSExtraction, SRSRequirement, TestingGeneration
__all__ = [
"User",
@@ -15,4 +15,5 @@ __all__ = [
"ToolJob",
"SRSExtraction",
"SRSRequirement",
"TestingGeneration",
]

View File

@@ -29,6 +29,13 @@ class ToolJob(Base, TimestampMixin):
uselist=False,
cascade="all, delete-orphan",
)
testing_generation = relationship(
"TestingGeneration",
back_populates="job",
uselist=False,
cascade="all, delete-orphan",
foreign_keys="TestingGeneration.job_id",
)
class SRSExtraction(Base, TimestampMixin):
@@ -63,9 +70,14 @@ class SRSRequirement(Base, TimestampMixin):
priority = Column(String(16), nullable=False, default="")
acceptance_criteria = Column(JSON, nullable=False)
source_field = Column(String(255), nullable=False)
section_uid = Column(String(64), nullable=True)
section_number = Column(String(64), nullable=True)
section_title = Column(String(255), nullable=True)
requirement_type = Column(String(64), nullable=True)
interface_name = Column(String(255), nullable=True)
interface_type = Column(String(128), nullable=True)
data_source = Column(String(255), nullable=True)
data_destination = Column(String(255), nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
extraction = relationship("SRSExtraction", back_populates="requirements")
@@ -74,3 +86,19 @@ class SRSRequirement(Base, TimestampMixin):
sa.UniqueConstraint("extraction_id", "requirement_uid", name="uq_srs_extraction_requirement_uid"),
sa.Index("idx_srs_requirements_extraction_sort", "extraction_id", "sort_order"),
)
class TestingGeneration(Base, TimestampMixin):
__tablename__ = "testing_generations"
id = Column(Integer, primary_key=True, index=True)
job_id = Column(Integer, ForeignKey("tool_jobs.id", ondelete="CASCADE"), nullable=False, unique=True)
source_job_id = Column(Integer, ForeignKey("tool_jobs.id"), nullable=True, index=True)
source_document_name = Column(String(255), nullable=False)
generated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
total_requirements = Column(Integer, nullable=False, default=0)
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=True, index=True)
generated_file = Column(JSON, nullable=False)
job = relationship("ToolJob", back_populates="testing_generation", foreign_keys=[job_id])

View File

@@ -29,14 +29,18 @@ class SRSToolJobStatusResponse(BaseModel):
class SRSToolRequirementItem(BaseModel):
id: str
title: str
description: str
priority: str
acceptanceCriteria: List[str]
sourceField: str
sectionUid: Optional[str] = None
sectionNumber: Optional[str] = None
sectionTitle: Optional[str] = None
requirementType: Optional[str] = None
interfaceName: Optional[str] = None
interfaceType: Optional[str] = None
dataSource: Optional[str] = None
dataDestination: Optional[str] = None
sortOrder: int
@@ -46,7 +50,72 @@ class SRSToolResultResponse(BaseModel):
generatedAt: str
statistics: Dict[str, Any]
requirements: List[SRSToolRequirementItem]
rawOutput: Dict[str, Any]
class SRSToolHistoryItem(BaseModel):
jobId: int
documentName: str
generatedAt: str
totalRequirements: int
status: str
createdAt: str
updatedAt: str
class SRSToolRequirementsSaveRequest(BaseModel):
requirements: List[SRSToolRequirementItem]
class TestingGenerationSaveRequest(BaseModel):
source_job_id: Optional[int] = None
source_document_name: str
knowledge_base_id: Optional[int] = None
generated_file: Dict[str, Any]
class TestingGenerationCreateRequest(BaseModel):
source_job_id: Optional[int] = None
source_document_name: str
knowledge_base_id: Optional[int] = None
requirements: List[SRSToolRequirementItem]
class TestingGenerationCreateResponse(BaseModel):
job_id: int
status: str
class TestingGenerationJobStatusResponse(BaseModel):
job_id: int
tool_name: str
status: str
error_message: Optional[str] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
source_document_name: Optional[str] = None
current_step: Optional[int] = None
total_steps: Optional[int] = None
current_requirement_id: Optional[str] = None
class TestingGenerationResultResponse(BaseModel):
jobId: int
sourceJobId: Optional[int] = None
sourceDocumentName: str
generatedAt: str
totalRequirements: int
knowledgeBaseId: Optional[int] = None
generatedFile: Dict[str, Any]
class TestingGenerationHistoryItem(BaseModel):
jobId: int
sourceJobId: Optional[int] = None
sourceDocumentName: str
generatedAt: str
totalRequirements: int
knowledgeBaseId: Optional[int] = None
status: str
createdAt: str
updatedAt: str

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Dict, List, Tuple
from sqlalchemy.orm import Session
@@ -10,6 +11,45 @@ from app.db.session import SessionLocal
from app.models.tooling import SRSExtraction, SRSRequirement, ToolJob
from app.tools.srs_reqs_qwen import get_srs_tool
TYPE_TO_CHINESE = {
"functional": "功能需求",
"interface": "接口需求",
"performance": "性能需求",
"security": "安全需求",
"reliability": "可靠性需求",
"other": "其他需求",
}
def _build_internal_title(description: Any, fallback: str, index: int = 0) -> str:
text = str(description or "").strip()
if not text:
return fallback or f"需求项 {index + 1}"
for separator in ("", "", "\n", ";", "."):
if separator in text:
text = text.split(separator, 1)[0].strip()
break
text = text[:20].strip()
return text or fallback or f"需求项 {index + 1}"
def _normalize_requirement_type(value: Any) -> str:
text = str(value or "").strip()
if text in {"functional", "interface", "performance", "security", "reliability", "other"}:
return text
chinese_map = {
"接口需求": "interface",
"性能需求": "performance",
"安全需求": "security",
"可靠性需求": "reliability",
"其他需求": "other",
"功能需求": "functional",
}
return chinese_map.get(text, "functional")
def run_srs_job(job_id: int) -> None:
db = SessionLocal()
@@ -41,14 +81,19 @@ def run_srs_job(job_id: int) -> None:
requirement = SRSRequirement(
extraction_id=extraction.id,
requirement_uid=item["id"],
title=item.get("title") or item["id"],
title=_build_internal_title(item.get("description"), item["id"]),
description=item.get("description") or "",
priority=item.get("priority") or "",
acceptance_criteria=item.get("acceptance_criteria") or ["待补充验收标准"],
source_field=item.get("source_field") or "文档解析",
section_uid=item.get("section_uid"),
section_number=item.get("section_number"),
section_title=item.get("section_title"),
requirement_type=item.get("requirement_type"),
interface_name=item.get("interface_name"),
interface_type=item.get("interface_type"),
data_source=item.get("data_source"),
data_destination=item.get("data_destination"),
sort_order=int(item.get("sort_order") or 0),
)
db.add(requirement)
@@ -97,22 +142,8 @@ def ensure_upload_path(job_id: int, file_name: str) -> Path:
def build_result_response(job: ToolJob, extraction: SRSExtraction) -> Dict[str, Any]:
requirements: List[Dict[str, Any]] = []
for item in extraction.requirements:
requirements.append(
{
"id": item.requirement_uid,
"title": item.title,
"description": item.description,
"priority": item.priority,
"acceptanceCriteria": item.acceptance_criteria or [],
"sourceField": item.source_field,
"sectionNumber": item.section_number,
"sectionTitle": item.section_title,
"requirementType": item.requirement_type,
"sortOrder": item.sort_order,
}
)
requirements = [_requirement_model_to_payload(item) for item in extraction.requirements]
raw_output = _merge_updates_into_raw_output(extraction.raw_output, requirements, extraction.document_name)
return {
"jobId": job.id,
@@ -120,10 +151,12 @@ def build_result_response(job: ToolJob, extraction: SRSExtraction) -> Dict[str,
"generatedAt": extraction.generated_at.isoformat(),
"statistics": extraction.statistics or {},
"requirements": requirements,
"rawOutput": raw_output,
}
def replace_requirements(db: Session, extraction: SRSExtraction, updates: List[Dict[str, Any]]) -> None:
normalized_updates = [_normalize_update_payload(item, index) for index, item in enumerate(updates)]
existing = {
req.requirement_uid: req
for req in db.query(SRSRequirement)
@@ -132,51 +165,95 @@ def replace_requirements(db: Session, extraction: SRSExtraction, updates: List[D
}
seen_ids = set()
for index, item in enumerate(updates):
for index, item in enumerate(normalized_updates):
uid = item["id"]
seen_ids.add(uid)
req = existing.get(uid)
if req is None:
description = item.get("description") if item.get("description") is not None else ""
req = SRSRequirement(
extraction_id=extraction.id,
requirement_uid=uid,
title=item.get("title") or uid,
description=item.get("description") if item.get("description") is not None else "",
title=_build_internal_title(description, uid, int(item.get("sortOrder") or index)),
description=description,
priority=item.get("priority") or "",
acceptance_criteria=item.get("acceptanceCriteria") or ["待补充验收标准"],
source_field=item.get("sourceField") or "文档解析",
section_uid=item.get("sectionUid"),
section_number=item.get("sectionNumber"),
section_title=item.get("sectionTitle"),
requirement_type=item.get("requirementType"),
sort_order=int(item.get("sortOrder") or index),
interface_name=item.get("interfaceName"),
interface_type=item.get("interfaceType"),
data_source=item.get("dataSource"),
data_destination=item.get("dataDestination"),
sort_order=int(item.get("sortOrder") or 0),
)
db.add(req)
continue
req.title = item.get("title", req.title)
req.description = item.get("description", req.description)
req.title = _build_internal_title(req.description, req.requirement_uid, int(item.get("sortOrder") or index))
req.priority = item.get("priority", req.priority)
req.acceptance_criteria = item.get("acceptanceCriteria", req.acceptance_criteria)
req.source_field = item.get("sourceField", req.source_field)
req.section_uid = item.get("sectionUid", req.section_uid)
req.section_number = item.get("sectionNumber", req.section_number)
req.section_title = item.get("sectionTitle", req.section_title)
req.requirement_type = item.get("requirementType", req.requirement_type)
req.sort_order = int(item.get("sortOrder", index))
req.interface_name = item.get("interfaceName", req.interface_name)
req.interface_type = item.get("interfaceType", req.interface_type)
req.data_source = item.get("dataSource", req.data_source)
req.data_destination = item.get("dataDestination", req.data_destination)
req.sort_order = int(item.get("sortOrder", req.sort_order))
for uid, req in existing.items():
if uid not in seen_ids:
db.delete(req)
extraction.total_requirements = len(updates)
extraction.total_requirements = len(normalized_updates)
extraction.statistics = {
"total": len(updates),
"by_type": _count_requirement_types(updates),
}
extraction.raw_output = {
"document_name": extraction.document_name,
"generated_at": extraction.generated_at.isoformat(),
"requirements": updates,
"total": len(normalized_updates),
"by_type": _count_requirement_types(normalized_updates),
}
extraction.raw_output = _merge_updates_into_raw_output(
extraction.raw_output,
normalized_updates,
extraction.document_name,
)
def list_srs_history(db: Session, user_id: int) -> List[Dict[str, Any]]:
records: List[Tuple[ToolJob, SRSExtraction]] = (
db.query(ToolJob, SRSExtraction)
.join(SRSExtraction, SRSExtraction.job_id == ToolJob.id)
.filter(ToolJob.user_id == user_id)
.order_by(ToolJob.created_at.desc())
.all()
)
items: List[Dict[str, Any]] = []
for job, extraction in records:
items.append(
{
"jobId": job.id,
"documentName": extraction.document_name,
"generatedAt": extraction.generated_at.isoformat(),
"totalRequirements": extraction.total_requirements,
"status": job.status,
"createdAt": job.created_at.isoformat(),
"updatedAt": job.updated_at.isoformat(),
}
)
return items
def delete_srs_job(db: Session, job: ToolJob) -> None:
db.delete(job)
db.commit()
def build_srs_upload_path(job_id: int) -> Path:
return Path("uploads") / "srs_jobs" / str(job_id)
def _count_requirement_types(items: List[Dict[str, Any]]) -> Dict[str, int]:
@@ -185,3 +262,210 @@ def _count_requirement_types(items: List[Dict[str, Any]]) -> Dict[str, int]:
req_type = item.get("requirementType") or "functional"
stats[req_type] = stats.get(req_type, 0) + 1
return stats
def _requirement_model_to_payload(item: SRSRequirement) -> Dict[str, Any]:
return {
"id": item.requirement_uid,
"description": item.description,
"priority": item.priority,
"acceptanceCriteria": item.acceptance_criteria or [],
"sourceField": item.source_field,
"sectionUid": item.section_uid,
"sectionNumber": item.section_number,
"sectionTitle": item.section_title,
"requirementType": item.requirement_type,
"interfaceName": item.interface_name,
"interfaceType": item.interface_type,
"dataSource": item.data_source,
"dataDestination": item.data_destination,
"sortOrder": item.sort_order,
}
def _normalize_update_payload(item: Dict[str, Any], index: int) -> Dict[str, Any]:
requirement_type = _normalize_requirement_type(item.get("requirementType"))
normalized = {
"id": str(item.get("id") or f"REQ-{index + 1:03d}"),
"description": str(item.get("description") or "").strip(),
"priority": item.get("priority") or "",
"acceptanceCriteria": item.get("acceptanceCriteria") or ["待补充验收标准"],
"sourceField": item.get("sourceField") or "文档解析",
"sectionUid": item.get("sectionUid"),
"sectionNumber": item.get("sectionNumber"),
"sectionTitle": item.get("sectionTitle"),
"requirementType": requirement_type,
"interfaceName": item.get("interfaceName") or "",
"interfaceType": item.get("interfaceType") or "",
"dataSource": item.get("dataSource") or "",
"dataDestination": item.get("dataDestination") or "",
"sortOrder": int(item.get("sortOrder") or index),
}
if requirement_type != "interface":
normalized["interfaceName"] = ""
normalized["interfaceType"] = ""
normalized["dataSource"] = ""
normalized["dataDestination"] = ""
return normalized
def _merge_updates_into_raw_output(
raw_output: Dict[str, Any] | None,
updates: List[Dict[str, Any]],
document_name: str,
) -> Dict[str, Any]:
if not isinstance(raw_output, dict) or "需求内容" not in raw_output:
return _build_raw_output_from_flat(updates, document_name)
result = deepcopy(raw_output)
content = result.get("需求内容")
if not isinstance(content, dict):
return _build_raw_output_from_flat(updates, document_name)
updates_by_section = _group_updates_by_section(updates)
_rewrite_content_requirements(content, updates_by_section)
_append_unmatched_sections(content, updates_by_section)
_refresh_metadata(result, updates)
return result
def _group_updates_by_section(updates: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
grouped: Dict[str, List[Dict[str, Any]]] = {}
for item in updates:
key = _section_key(item.get("sectionUid"), item.get("sectionNumber"), item.get("sectionTitle"))
grouped.setdefault(key, []).append(item)
for values in grouped.values():
values.sort(key=lambda value: int(value.get("sortOrder") or 0))
return grouped
def _rewrite_content_requirements(content: Dict[str, Any], updates_by_section: Dict[str, List[Dict[str, Any]]]) -> None:
for section in content.values():
if not isinstance(section, dict):
continue
section_info = section.get("章节信息") or {}
section_key = _section_key(
section_info.get("章节UID"),
section_info.get("章节编号"),
section_info.get("章节标题"),
)
if section_key in updates_by_section:
section["需求列表"] = [_to_raw_requirement_item(item) for item in updates_by_section.pop(section_key)]
elif "需求列表" in section:
section["需求列表"] = []
children = section.get("子章节")
if isinstance(children, dict):
_rewrite_content_requirements(children, updates_by_section)
def _append_unmatched_sections(content: Dict[str, Any], updates_by_section: Dict[str, List[Dict[str, Any]]]) -> None:
if not updates_by_section:
return
orphan_key = "未归类章节"
orphan_section = content.get(orphan_key)
if not isinstance(orphan_section, dict):
orphan_section = {
"章节信息": {
"章节编号": "",
"章节标题": orphan_key,
"章节级别": 1,
},
"需求列表": [],
}
content[orphan_key] = orphan_section
all_reqs: List[Dict[str, Any]] = orphan_section.get("需求列表") or []
for values in updates_by_section.values():
for item in values:
all_reqs.append(_to_raw_requirement_item(item))
orphan_section["需求列表"] = all_reqs
updates_by_section.clear()
def _refresh_metadata(raw_output: Dict[str, Any], updates: List[Dict[str, Any]]) -> None:
metadata = raw_output.get("文档元数据")
if not isinstance(metadata, dict):
metadata = {}
raw_output["文档元数据"] = metadata
metadata["总需求数"] = len(updates)
metadata["生成时间"] = datetime.now().isoformat()
type_stats: Dict[str, int] = {}
for item in updates:
req_type = item.get("requirementType") or "functional"
cn_type = TYPE_TO_CHINESE.get(req_type, "其他需求")
type_stats[cn_type] = type_stats.get(cn_type, 0) + 1
metadata["需求类型统计"] = type_stats
def _to_raw_requirement_item(item: Dict[str, Any]) -> Dict[str, Any]:
req_type = item.get("requirementType") or "functional"
raw_item = {
"需求类型": TYPE_TO_CHINESE.get(req_type, "其他需求"),
"需求编号": item.get("id") or "",
"需求描述": item.get("description") or "",
"优先级": item.get("priority") or "",
}
if req_type == "interface":
raw_item["接口名称"] = item.get("interfaceName") or ""
raw_item["接口类型"] = item.get("interfaceType") or ""
raw_item["数据来源"] = item.get("dataSource") or ""
raw_item["数据目的地"] = item.get("dataDestination") or ""
return raw_item
def _build_raw_output_from_flat(updates: List[Dict[str, Any]], document_name: str) -> Dict[str, Any]:
grouped = _group_updates_by_section(updates)
content: Dict[str, Any] = {}
for key, values in grouped.items():
number, title = _parse_section_key(key)
section_title = title or "未归类章节"
display_key = f"{number} {section_title}".strip()
content[display_key] = {
"章节信息": {
"章节编号": number,
"章节标题": section_title,
"章节级别": max(len(number.split(".")), 1) if number else 1,
},
"需求列表": [_to_raw_requirement_item(item) for item in values],
}
raw_output = {
"文档元数据": {
"标题": document_name,
"生成时间": datetime.now().isoformat(),
"总需求数": len(updates),
"需求类型统计": {},
},
"需求内容": content,
}
_refresh_metadata(raw_output, updates)
return raw_output
def _section_key(section_uid: Any, section_number: Any, section_title: Any) -> str:
uid = str(section_uid or "").strip()
if uid:
return f"uid::{uid}"
number = str(section_number or "").strip()
title = str(section_title or "").strip()
return f"number::{number}::title::{title}"
def _parse_section_key(value: str) -> Tuple[str, str]:
if value.startswith("uid::"):
return "", "未归类章节"
number = ""
title = ""
parts = value.split("::")
if len(parts) >= 4:
number = parts[1]
title = parts[3]
return number, title

View File

@@ -0,0 +1,236 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.knowledge import Document, KnowledgeBase
from app.models.tooling import TestingGeneration, ToolJob
from app.services.embedding.embedding_factory import EmbeddingsFactory
from app.services.retrieval.multi_kb_retriever import MultiKBRetriever, format_retrieval_context
from app.services.testing_pipeline import run_testing_pipeline
from app.services.vector_store import VectorStoreFactory
def _flatten_record(value: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
for current in value.values():
items.extend(current)
return items
def _build_kb_vector_stores(db: Session, knowledge_bases: List[KnowledgeBase]) -> List[Dict[str, Any]]:
embeddings = EmbeddingsFactory.create()
kb_vector_stores: List[Dict[str, Any]] = []
for kb in knowledge_bases:
documents = db.query(Document).filter(Document.knowledge_base_id == kb.id).all()
if not documents:
continue
store = VectorStoreFactory.create(
store_type=settings.VECTOR_STORE_TYPE,
collection_name=f"kb_{kb.id}",
embedding_function=embeddings,
)
kb_vector_stores.append({"kb_id": kb.id, "store": store})
return kb_vector_stores
def _resolve_knowledge_context(
db: Session,
*,
user_id: int,
requirement_text: str,
knowledge_base_id: int | None,
) -> str:
if knowledge_base_id is None:
return ""
try:
knowledge_bases = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.user_id == user_id,
)
.all()
)
kb_vector_stores = _build_kb_vector_stores(db, knowledge_bases)
if not kb_vector_stores:
return ""
retriever = MultiKBRetriever(
reranker_weight=settings.RERANKER_WEIGHT,
)
rows = asyncio.run(
retriever.retrieve(
query=requirement_text,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=16,
top_k=8,
)
)
if rows:
return format_retrieval_context(rows)
except Exception:
return ""
return ""
def _build_generated_requirement(req: Dict[str, Any], pipeline_result: Dict[str, Any]) -> Dict[str, Any]:
test_items = [
{
"id": item.get("id"),
"content": item.get("content"),
}
for item in _flatten_record(pipeline_result.get("test_items", {}))
]
test_cases = [
{
"id": item.get("id"),
"itemId": item.get("item_id"),
"testContent": item.get("test_content"),
"operationSteps": item.get("operation_steps", []),
"expectedResultPlaceholder": item.get("expected_result_placeholder"),
}
for item in _flatten_record(pipeline_result.get("test_cases", {}))
]
expected_results = [
{
"id": item.get("id"),
"caseId": item.get("case_id"),
"result": item.get("result"),
}
for item in _flatten_record(pipeline_result.get("expected_results", {}))
]
return {
**req,
"测试项": test_items,
"测试用例": test_cases,
"预期结果": expected_results,
}
def _mark_job_failed(job_id: int, error_message: str) -> None:
db = SessionLocal()
try:
job = db.query(ToolJob).filter(ToolJob.id == job_id).first()
if not job:
return
job.status = "failed"
job.completed_at = datetime.utcnow()
job.error_message = error_message[:2000]
db.commit()
finally:
db.close()
def run_testing_generation_job(job_id: int, payload: Dict[str, Any]) -> None:
db = SessionLocal()
try:
job = db.query(ToolJob).filter(ToolJob.id == job_id).first()
if not job:
return
requirements = payload.get("requirements") or []
source_document_name = str(payload.get("source_document_name") or job.input_file_name or "")
source_job_id = payload.get("source_job_id")
knowledge_base_id = payload.get("knowledge_base_id")
job.status = "processing"
job.started_at = datetime.utcnow()
job.error_message = None
job.output_summary = {
"source_document_name": source_document_name,
"current_step": 0,
"total_steps": len(requirements),
}
db.commit()
generated_requirements: List[Dict[str, Any]] = []
for index, req in enumerate(requirements):
req_id = str(req.get("id") or f"REQ-{index + 1:03d}")
job.output_summary = {
"source_document_name": source_document_name,
"current_step": index + 1,
"total_steps": len(requirements),
"current_requirement_id": req_id,
}
db.commit()
description = str(req.get("description") or "").strip()
if not description:
generated_requirements.append(
{
**req,
"测试项": [],
"测试用例": [],
"预期结果": [],
}
)
continue
knowledge_context = _resolve_knowledge_context(
db,
user_id=job.user_id,
requirement_text=description,
knowledge_base_id=knowledge_base_id,
)
pipeline_result = run_testing_pipeline(
user_requirement_text=description,
requirement_type_input=req.get("requirementType"),
debug=False,
knowledge_context=knowledge_context,
use_model_generation=True,
max_items_per_group=12,
cases_per_item=2,
max_focus_points=6,
max_llm_calls=10,
)
generated_requirements.append(_build_generated_requirement(req, pipeline_result))
generated_at = datetime.utcnow()
generated_file = {
"sourceDocument": source_document_name,
"sourceJobId": source_job_id,
"generatedAt": generated_at.isoformat(),
"totalRequirements": len(generated_requirements),
"knowledgeBaseId": knowledge_base_id,
"requirements": generated_requirements,
}
generation = TestingGeneration(
job_id=job.id,
source_job_id=source_job_id,
source_document_name=source_document_name,
generated_at=generated_at,
total_requirements=len(generated_requirements),
knowledge_base_id=knowledge_base_id,
generated_file=generated_file,
)
db.add(generation)
job.status = "completed"
job.completed_at = datetime.utcnow()
job.output_summary = {
"source_document_name": source_document_name,
"current_step": len(generated_requirements),
"total_steps": len(generated_requirements),
"total_requirements": len(generated_requirements),
"knowledge_base_id": knowledge_base_id,
}
db.commit()
except Exception as exc:
db.rollback()
_mark_job_failed(job_id, str(exc))
finally:
db.close()

View File

@@ -0,0 +1,111 @@
from datetime import datetime
from typing import Any, Dict, List, Tuple
from sqlalchemy.orm import Session
from app.models.tooling import TestingGeneration, ToolJob
from app.schemas.tooling import TestingGenerationSaveRequest
TESTING_TOOL_NAME = "testing.case_generator"
def _resolve_total_requirements(generated_file: Dict[str, Any]) -> int:
requirements = generated_file.get("requirements")
if isinstance(requirements, list):
return len(requirements)
total = generated_file.get("totalRequirements")
if isinstance(total, int) and total >= 0:
return total
return 0
def build_testing_generation_response(job: ToolJob, generation: TestingGeneration) -> Dict[str, Any]:
return {
"jobId": job.id,
"sourceJobId": generation.source_job_id,
"sourceDocumentName": generation.source_document_name,
"generatedAt": generation.generated_at.isoformat(),
"totalRequirements": generation.total_requirements,
"knowledgeBaseId": generation.knowledge_base_id,
"generatedFile": generation.generated_file or {},
}
def create_testing_generation(
db: Session,
user_id: int,
payload: TestingGenerationSaveRequest,
) -> Dict[str, Any]:
now = datetime.utcnow()
total_requirements = _resolve_total_requirements(payload.generated_file)
job = ToolJob(
user_id=user_id,
tool_name=TESTING_TOOL_NAME,
status="completed",
input_file_name=payload.source_document_name,
input_file_path="",
started_at=now,
completed_at=now,
output_summary={
"source_document_name": payload.source_document_name,
"total_requirements": total_requirements,
"knowledge_base_id": payload.knowledge_base_id,
},
)
db.add(job)
db.flush()
generation = TestingGeneration(
job_id=job.id,
source_job_id=payload.source_job_id,
source_document_name=payload.source_document_name,
generated_at=now,
total_requirements=total_requirements,
knowledge_base_id=payload.knowledge_base_id,
generated_file=payload.generated_file,
)
db.add(generation)
db.commit()
db.refresh(job)
db.refresh(generation)
return build_testing_generation_response(job, generation)
def list_testing_history(db: Session, user_id: int) -> List[Dict[str, Any]]:
rows: List[Tuple[ToolJob, TestingGeneration]] = (
db.query(ToolJob, TestingGeneration)
.join(TestingGeneration, TestingGeneration.job_id == ToolJob.id)
.filter(
ToolJob.user_id == user_id,
ToolJob.tool_name == TESTING_TOOL_NAME,
)
.order_by(ToolJob.created_at.desc())
.all()
)
items: List[Dict[str, Any]] = []
for job, generation in rows:
items.append(
{
"jobId": job.id,
"sourceJobId": generation.source_job_id,
"sourceDocumentName": generation.source_document_name,
"generatedAt": generation.generated_at.isoformat(),
"totalRequirements": generation.total_requirements,
"knowledgeBaseId": generation.knowledge_base_id,
"status": job.status,
"createdAt": job.created_at.isoformat(),
"updatedAt": job.updated_at.isoformat(),
}
)
return items
def delete_testing_generation(db: Session, job: ToolJob) -> None:
db.delete(job)
db.commit()

View File

@@ -4,7 +4,6 @@ from time import perf_counter
from typing import Any, Dict, List, Optional
from uuid import uuid4
from app.services.llm.llm_factory import LLMFactory
from app.services.testing_pipeline.tools import build_default_tool_chain
@@ -42,6 +41,8 @@ def run_testing_pipeline(
llm_model = None
if use_model_generation:
try:
from app.services.llm.llm_factory import LLMFactory
llm_model = LLMFactory.create(streaming=False)
except Exception:
llm_model = None

View File

@@ -137,6 +137,19 @@ class DocumentParser(ABC):
chinese_numbers = '一二三四五六七八九十百千万'
return text and all(c in chinese_numbers for c in text)
def _section_sort_key(self, section: 'Section') -> Tuple[int, List[int], str]:
number = (section.number or "").strip()
if number and re.match(r'^\d+(?:\.\d+)*$', number):
return (0, [int(part) for part in number.split('.')], section.title or "")
return (1, [section.level], section.title or "")
def _sort_sections_by_number(self, sections: List['Section']) -> List['Section']:
ordered = sorted(sections, key=self._section_sort_key)
for section in ordered:
if section.children:
section.children = self._sort_sections_by_number(section.children)
return ordered
class DocxParser(DocumentParser):
"""DOCX格式文档解析器"""
@@ -210,6 +223,7 @@ class DocxParser(DocumentParser):
# 为没有编号的章节自动生成编号
self._auto_number_sections(self.sections)
self.sections = self._sort_sections_by_number(self.sections)
logger.info(f"完成Docx解析提取{len(self.sections)}个顶级章节")
return self.sections
@@ -236,12 +250,17 @@ class DocxParser(DocumentParser):
"""解析标题,返回(编号, 标题, 级别)"""
style_name = paragraph.style.name if paragraph.style else ""
is_heading_style = style_name.lower().startswith('heading') if style_name else False
if self._is_calendar_line(text):
return None
# 数字编号标题
match = re.match(r'^(\d+(?:\.\d+)*)\s*[\.、]?\s*(.+)$', text)
match = re.match(r'^(\d+(?:\.\d+)*)\s*[\.、):\-_/]?\s*(.+)$', text)
if match and self._is_valid_heading(match.group(2)):
number = match.group(1)
title = match.group(2).strip()
if not self._is_valid_numbered_heading(number, title):
return None
level = len(number.split('.'))
return number, title, level
@@ -263,6 +282,31 @@ class DocxParser(DocumentParser):
return None
def _is_calendar_line(self, text: str) -> bool:
value = (text or "").strip().replace(" ", "")
return bool(re.match(r'^\d{4}\d{1,2}月(?:\d{1,2}日)?$', value))
def _is_valid_numbered_heading(self, number: str, title: str) -> bool:
parts = number.split('.')
if len(parts) > 6:
return False
first = int(parts[0])
if first < 1 or first > 30:
return False
for part in parts[1:]:
if int(part) > 30:
return False
if len(parts) == 1 and re.match(r'^年\d{1,2}月', title):
return False
if title and title[0].isdigit():
return False
return True
def _iter_block_items(self, parent):
"""按文档顺序迭代段落和表格"""
from docx.text.paragraph import Paragraph
@@ -356,6 +400,7 @@ class PDFParser(DocumentParser):
# 6. 为没有编号的章节自动生成编号
self._auto_number_sections(self.sections)
self.sections = self._sort_sections_by_number(self.sections)
logger.info(f"完成PDF解析提取{len(self.sections)}个顶级章节")
return self.sections
@@ -599,18 +644,6 @@ class PDFParser(DocumentParser):
level = len(number.split('.'))
top_level_number = int(number.split('.')[0])
# 顶级章节序号大幅跳跃通常是误识别如正文中的“8 表...”)。
if level == 1 and last_top_level_number and top_level_number > last_top_level_number + 1:
if line and not self._is_noise(line):
content_buffer.append(line)
continue
# 顶级章节编号倒退通常是正文枚举项被误识别如“1 综合监控...”)。
if level == 1 and last_top_level_number and top_level_number < last_top_level_number:
if line and not self._is_noise(line):
content_buffer.append(line)
continue
if level > 6:
continue
@@ -645,10 +678,6 @@ class PDFParser(DocumentParser):
for l in list(section_stack.keys()):
if l > level:
del section_stack[l]
# 若出现层级跳跃如1->3自动回退到父级+1。
if level > 1 and (level - 1) not in section_stack:
section.level = max(section_stack.keys()) if section_stack else 1
current_section = section
else:
@@ -670,7 +699,10 @@ class PDFParser(DocumentParser):
(章节编号, 章节标题) 或 None
"""
# 模式: "3.1 功能需求" / "3.1.2 电场..."
match = re.match(r'^(\d+(?:\.\d+)*)[\s、.)]*(.+)$', line)
if self._is_calendar_line(line):
return None
match = re.match(r'^(\d+(?:\.\d+)*)[\s、.):\-_/]*(.+)$', line)
if not match:
return None
@@ -692,7 +724,7 @@ class PDFParser(DocumentParser):
# 检查子部分是否合理
for part in parts[1:]:
if int(part) > 20:
if int(part) > 30:
return None
# 避免重复
@@ -747,6 +779,10 @@ class PDFParser(DocumentParser):
return (number, title)
def _is_calendar_line(self, text: str) -> bool:
value = (text or "").strip().replace(" ", "")
return bool(re.match(r'^\d{4}\d{1,2}月(?:\d{1,2}日)?$', value))
def _looks_like_statement(self, title: str) -> bool:
"""判断标题是否更像正文语句而非章节名。"""
if not title:

View File

@@ -51,6 +51,8 @@ class SRSTool:
"other": "",
}
UNKNOWN_INTERFACE_VALUES = {"", "未知", "unknown", "n/a", "-", "--", "", "none", "null"}
def __init__(self) -> None:
ToolRegistry.register(self.DEFINITION)
@@ -90,24 +92,78 @@ class SRSTool:
normalized: List[Dict[str, Any]] = []
for index, req in enumerate(extracted, start=1):
description = (req.description or "").strip()
title = description[:40] if description else f"需求项 {index}"
title = self._build_short_title(description, index)
requirement_type = self._normalize_requirement_type(
req_type=getattr(req, "type", "functional"),
interface_name=getattr(req, "interface_name", ""),
interface_type=getattr(req, "interface_type", ""),
data_source=getattr(req, "source", ""),
data_destination=getattr(req, "destination", ""),
)
source_field = f"{req.section_number} {req.section_title}".strip() or "文档解析"
normalized.append(
{
"id": req.id,
"title": title,
"description": description,
"priority": self.PRIORITY_BY_TYPE.get(req.type, ""),
"priority": "",
"acceptance_criteria": [description] if description else ["待补充验收标准"],
"source_field": source_field,
"section_uid": req.section_uid,
"section_number": req.section_number,
"section_title": req.section_title,
"requirement_type": req.type,
"requirement_type": requirement_type,
"interface_name": req.interface_name if requirement_type == "interface" else "",
"interface_type": req.interface_type if requirement_type == "interface" else "",
"data_source": req.source if requirement_type == "interface" else "",
"data_destination": req.destination if requirement_type == "interface" else "",
"sort_order": index,
}
)
return normalized
def _normalize_requirement_type(
self,
req_type: Any,
interface_name: Any,
interface_type: Any,
data_source: Any,
data_destination: Any,
) -> str:
raw_type = str(req_type or "").strip()
mapping = {
"功能需求": "functional",
"接口需求": "interface",
"性能需求": "performance",
"安全需求": "security",
"可靠性需求": "reliability",
"其他需求": "other",
}
normalized_type = mapping.get(raw_type, raw_type)
if normalized_type not in self.PRIORITY_BY_TYPE:
normalized_type = "functional"
fields = [interface_name, interface_type, data_source, data_destination]
has_interface_fields = any(
str(value or "").strip().lower() not in self.UNKNOWN_INTERFACE_VALUES for value in fields
)
if normalized_type == "interface" or has_interface_fields:
return "interface"
return normalized_type
def _build_short_title(self, description: str, index: int) -> str:
text = (description or "").strip()
if not text:
return f"需求项 {index}"
for separator in ("", "", "\n", ";", "."):
if separator in text:
text = text.split(separator, 1)[0].strip()
break
if len(text) <= 20:
return text
return f"{text[:20].rstrip()}"
def _load_config(self) -> Dict[str, Any]:
config_path = Path(__file__).with_name("default_config.yaml")
if config_path.exists():

View File

@@ -94,7 +94,7 @@ export default function NewChatPage() {
</p>
<Link
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
>
<Plus className="mr-2 h-4 w-4" />

View File

@@ -1,14 +1,17 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
ChevronRight,
Download,
FileJson,
FileText,
History,
Loader2,
Save,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
@@ -23,6 +26,23 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/components/ui/use-toast";
import {
downloadJson,
@@ -35,11 +55,15 @@ import {
} from "@/lib/document-mock";
import {
createSrsJob,
deleteSrsJob,
getSrsJobResult,
getSrsJobStatus,
listSrsHistory,
saveSrsRequirements,
SrsHistoryItem,
toExtractionResult,
} from "@/lib/srs-tools-api";
import { buildSectionTree, rebuildRawOutput, SectionTreeNode } from "@/lib/srs-json";
import { cn } from "@/lib/utils";
const formatDateTime = (value: string) => {
@@ -69,6 +93,89 @@ const wait = (ms: number) =>
const EXTRACTION_JOB_KEY = "doc_processing_extraction_job_id";
const normalizeRequirementType = (value: unknown): RequirementItem["requirementType"] => {
if (
value === "functional" ||
value === "interface" ||
value === "performance" ||
value === "security" ||
value === "reliability" ||
value === "other"
) {
return value;
}
if (value === "接口需求") {
return "interface";
}
if (value === "性能需求") {
return "performance";
}
if (value === "安全需求") {
return "security";
}
if (value === "可靠性需求") {
return "reliability";
}
if (value === "其他需求") {
return "other";
}
return "functional";
};
const hasInterfaceMetadata = (item: RequirementItem) => {
const candidates = [item.interfaceName, item.interfaceType, item.dataSource, item.dataDestination];
return candidates.some((field) => {
const value = (field || "").trim();
return Boolean(value) && !["未知", "unknown", "n/a", "-", "--", "无"].includes(value.toLowerCase());
});
};
const normalizeRequirement = (item: RequirementItem, index: number): RequirementItem => {
const normalizedType = normalizeRequirementType(item.requirementType);
const requirementType =
normalizedType === "interface" || hasInterfaceMetadata(item) ? "interface" : normalizedType;
return {
...item,
priority: item.priority || "中",
requirementType,
sectionNumber: item.sectionNumber || "",
sectionTitle: item.sectionTitle || "未归类章节",
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index,
interfaceName: requirementType === "interface" ? item.interfaceName || "" : "",
interfaceType: requirementType === "interface" ? item.interfaceType || "" : "",
dataSource: requirementType === "interface" ? item.dataSource || "" : "",
dataDestination: requirementType === "interface" ? item.dataDestination || "" : "",
};
};
const normalizeExtractionResult = (
extraction: RequirementExtractionResult
): RequirementExtractionResult => {
const requirements = extraction.requirements.map((item, index) =>
normalizeRequirement(item, index)
);
return {
...extraction,
requirements,
rawOutput: rebuildRawOutput(
extraction.rawOutput as Record<string, unknown> | undefined,
requirements,
extraction.documentName
),
};
};
const collectSectionKeys = (nodes: SectionTreeNode[]): string[] => {
const keys: string[] = [];
const walk = (node: SectionTreeNode) => {
keys.push(node.key);
node.children.forEach((child) => walk(child));
};
nodes.forEach((node) => walk(node));
return keys;
};
export default function RequirementExtractionPage() {
const [documentFile, setDocumentFile] = useState<File | null>(null);
const [extraction, setExtraction] = useState<RequirementExtractionResult | null>(
@@ -81,9 +188,35 @@ export default function RequirementExtractionPage() {
const [isSaving, setIsSaving] = useState(false);
const [activeJobId, setActiveJobId] = useState<number | null>(null);
const [isImportingJson, setIsImportingJson] = useState(false);
const [historyItems, setHistoryItems] = useState<SrsHistoryItem[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [expandedSectionKeys, setExpandedSectionKeys] = useState<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const jsonInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const setExtractionAndPersist = useCallback((value: RequirementExtractionResult | null) => {
if (!value) {
setExtraction(null);
return;
}
const normalized = normalizeExtractionResult(value);
setExtraction(normalized);
saveExtractionDraft(normalized);
}, []);
const loadHistory = useCallback(async () => {
setIsHistoryLoading(true);
try {
const items = await listSrsHistory();
setHistoryItems(items);
} catch {
setHistoryItems([]);
} finally {
setIsHistoryLoading(false);
}
}, []);
useEffect(() => {
const storedJobId = window.localStorage.getItem(EXTRACTION_JOB_KEY);
if (storedJobId) {
@@ -95,12 +228,15 @@ export default function RequirementExtractionPage() {
const draft = loadExtractionDraft();
if (!draft) {
void loadHistory();
return;
}
setExtraction(draft);
const normalized = normalizeExtractionResult(draft);
setExtraction(normalized);
setSelectedRequirementId(draft.requirements[0]?.id ?? null);
}, []);
void loadHistory();
}, [loadHistory]);
const onDrop = useCallback((acceptedFiles: File[]) => {
const first = acceptedFiles[0];
@@ -125,24 +261,74 @@ export default function RequirementExtractionPage() {
(item) => item.id === selectedRequirementId
);
const sectionTree = useMemo(() => {
if (!extraction) {
return [];
}
return buildSectionTree(
extraction.rawOutput as Record<string, unknown> | undefined,
extraction.requirements
);
}, [extraction]);
useEffect(() => {
if (!sectionTree.length) {
setExpandedSectionKeys([]);
return;
}
setExpandedSectionKeys((prev) => {
if (prev.length > 0) {
return prev;
}
return collectSectionKeys(sectionTree).slice(0, 2);
});
}, [sectionTree]);
const updateRequirement = (
requirementId: string,
updater: (item: RequirementItem) => RequirementItem
) => {
let nextSelected = selectedRequirementId;
setExtraction((prev) => {
if (!prev) {
return prev;
}
const index = prev.requirements.findIndex((item) => item.id === requirementId);
if (index < 0) {
return prev;
}
const current = prev.requirements[index];
const updated = normalizeRequirement(updater(current), index);
const requirements = [...prev.requirements];
requirements[index] = {
...updated,
sortOrder: index,
};
const next: RequirementExtractionResult = {
...prev,
requirements: prev.requirements.map((item) =>
item.id === requirementId ? updater(item) : item
requirements,
rawOutput: rebuildRawOutput(
prev.rawOutput as Record<string, unknown> | undefined,
requirements,
prev.documentName
),
};
saveExtractionDraft(next);
if (selectedRequirementId === requirementId) {
nextSelected = updated.id;
}
return next;
});
if (nextSelected) {
setSelectedRequirementId(nextSelected);
}
};
const handleExtract = async () => {
@@ -167,7 +353,7 @@ export default function RequirementExtractionPage() {
const status = await getSrsJobStatus(job.job_id);
if (status.status === "completed") {
const rawResult = await getSrsJobResult(job.job_id);
finalResult = toExtractionResult(rawResult);
finalResult = normalizeExtractionResult(toExtractionResult(rawResult));
break;
}
if (status.status === "failed") {
@@ -180,9 +366,9 @@ export default function RequirementExtractionPage() {
throw new Error("提取任务超时,请稍后重试");
}
setExtraction(finalResult);
setExtractionAndPersist(finalResult);
setSelectedRequirementId(finalResult.requirements[0]?.id ?? null);
saveExtractionDraft(finalResult);
await loadHistory();
toast({
title: "提取完成",
description: `已生成 ${finalResult.requirements.length} 条需求项。`,
@@ -210,11 +396,10 @@ export default function RequirementExtractionPage() {
setIsImportingJson(true);
try {
const content = await file.text();
const parsed = parseRequirementJson(content);
setExtraction(parsed);
const parsed = normalizeExtractionResult(parseRequirementJson(content));
setExtractionAndPersist(parsed);
setSelectedRequirementId(parsed.requirements[0]?.id ?? null);
setActiveJobId(null);
saveExtractionDraft(parsed);
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
toast({
title: "导入成功",
@@ -238,9 +423,11 @@ export default function RequirementExtractionPage() {
return;
}
const normalized = normalizeExtractionResult(extraction);
const persist = async () => {
if (!activeJobId) {
saveExtractionDraft(extraction);
setExtractionAndPersist(normalized);
toast({
title: "保存成功",
description: "当前需求编辑结果已保存到本地。",
@@ -250,10 +437,10 @@ export default function RequirementExtractionPage() {
setIsSaving(true);
try {
const saved = await saveSrsRequirements(activeJobId, extraction);
const next = toExtractionResult(saved);
setExtraction(next);
saveExtractionDraft(next);
const saved = await saveSrsRequirements(activeJobId, normalized);
const next = normalizeExtractionResult(toExtractionResult(saved));
setExtractionAndPersist(next);
await loadHistory();
toast({
title: "保存成功",
description: "修改内容已保存到服务端。",
@@ -279,13 +466,125 @@ export default function RequirementExtractionPage() {
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
downloadJson(`requirements-${timestamp}.json`, extraction);
const payload = rebuildRawOutput(
extraction.rawOutput as Record<string, unknown> | undefined,
extraction.requirements,
extraction.documentName
);
downloadJson(`requirements-${timestamp}.json`, payload);
toast({
title: "导出成功",
description: "JSON 文件已下载。",
});
};
const toggleSection = (sectionKey: string) => {
setExpandedSectionKeys((prev) =>
prev.includes(sectionKey)
? prev.filter((key) => key !== sectionKey)
: [...prev, sectionKey]
);
};
const handleLoadHistoryItem = async (jobId: number) => {
try {
const result = await getSrsJobResult(jobId);
const normalized = normalizeExtractionResult(toExtractionResult(result));
setExtractionAndPersist(normalized);
setSelectedRequirementId(normalized.requirements[0]?.id ?? null);
setActiveJobId(jobId);
window.localStorage.setItem(EXTRACTION_JOB_KEY, String(jobId));
setIsHistoryOpen(false);
toast({
title: "加载成功",
description: `已加载 ${normalized.documentName}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "加载历史文件失败";
toast({
title: "加载失败",
description: message,
variant: "destructive",
});
}
};
const handleDeleteHistoryItem = async (jobId: number) => {
try {
await deleteSrsJob(jobId);
if (activeJobId === jobId) {
setActiveJobId(null);
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
setExtraction(null);
setSelectedRequirementId(null);
}
await loadHistory();
toast({
title: "删除成功",
description: "历史文件已删除。",
});
} catch (error) {
const message = error instanceof Error ? error.message : "删除历史文件失败";
toast({
title: "删除失败",
description: message,
variant: "destructive",
});
}
};
const renderSectionNode = (node: SectionTreeNode) => {
const expanded = expandedSectionKeys.includes(node.key);
const sectionLabel =
`${node.sectionNumber ? `${node.sectionNumber} ` : ""}${node.sectionTitle}`.trim() ||
"未归类章节";
return (
<div key={node.key} className="space-y-2">
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted"
onClick={() => toggleSection(node.key)}
>
<ChevronRight
className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-90")}
/>
<div className="flex flex-1 items-center justify-between gap-2">
<p className="text-sm font-medium">{sectionLabel}</p>
<Badge variant="outline">{node.requirements.length}</Badge>
</div>
</button>
{expanded && (
<div className="space-y-2 border-l pl-4">
{node.requirements.map((item) => {
const active = item.id === selectedRequirementId;
return (
<button
key={`${node.key}-${item.id}`}
onClick={() => setSelectedRequirementId(item.id)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
active ? "border-primary bg-primary/5" : "hover:border-primary/40"
)}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm line-clamp-1">{item.id}</p>
<Badge variant={priorityVariant[item.priority]}>{item.priority}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{item.description}
</p>
<p className="mt-2 text-[11px] text-muted-foreground">{item.id}</p>
</button>
);
})}
{node.children.map((child) => renderSectionNode(child))}
</div>
)}
</div>
);
};
return (
<DashboardLayout>
<div className="space-y-6">
@@ -359,6 +658,78 @@ export default function RequirementExtractionPage() {
<Download className="mr-2 h-4 w-4" />
JSON
</Button>
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => void loadHistory()}>
<History className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isHistoryLoading && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
...
</TableCell>
</TableRow>
)}
{!isHistoryLoading && historyItems.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
{!isHistoryLoading &&
historyItems.map((item) => (
<TableRow key={item.jobId}>
<TableCell>{item.documentName}</TableCell>
<TableCell>{item.totalRequirements}</TableCell>
<TableCell>{formatDateTime(item.generatedAt)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleLoadHistoryItem(item.jobId)}
>
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void handleDeleteHistoryItem(item.jobId)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DialogContent>
</Dialog>
<input
ref={jsonInputRef}
type="file"
@@ -380,8 +751,8 @@ export default function RequirementExtractionPage() {
<div className="grid gap-6 lg:grid-cols-[320px_1fr]">
<Card className="min-h-[500px]">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{!extraction && (
@@ -391,34 +762,12 @@ export default function RequirementExtractionPage() {
)}
{extraction && (
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1">
{extraction.requirements.map((item) => {
const active = item.id === selectedRequirementId;
return (
<button
key={item.id}
onClick={() => setSelectedRequirementId(item.id)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "hover:border-primary/40"
)}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm line-clamp-1">{item.title}</p>
<Badge variant={priorityVariant[item.priority]}>
{item.priority}
</Badge>
</div>
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{item.description}
</p>
<p className="mt-2 text-[11px] text-muted-foreground">
{item.id} · {item.sourceField}
</p>
</button>
);
})}
{sectionTree.length === 0 && (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
</div>
)}
{sectionTree.map((node) => renderSectionNode(node))}
</div>
)}
</CardContent>
@@ -427,7 +776,7 @@ export default function RequirementExtractionPage() {
<Card className="min-h-[500px]">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription> JSON</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{!selectedRequirement && (
@@ -452,20 +801,6 @@ export default function RequirementExtractionPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-title"></Label>
<Input
id="req-title"
value={selectedRequirement.title}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
title: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-priority"></Label>
<select
@@ -486,17 +821,28 @@ export default function RequirementExtractionPage() {
</div>
<div className="space-y-2">
<Label htmlFor="req-source"></Label>
<Input
id="req-source"
value={selectedRequirement.sourceField}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
sourceField: event.target.value,
}))
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<Label htmlFor="is-interface"></Label>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Switch
id="is-interface"
checked={selectedRequirement.requirementType === "interface"}
onCheckedChange={(checked) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
requirementType: checked ? "interface" : "functional",
interfaceName: checked ? item.interfaceName || "" : "",
interfaceType: checked ? item.interfaceType || "" : "",
dataSource: checked ? item.dataSource || "" : "",
dataDestination: checked ? item.dataDestination || "" : "",
}))
}
/>
</div>
</div>
<div className="space-y-2">
@@ -528,6 +874,66 @@ export default function RequirementExtractionPage() {
}
/>
</div>
{selectedRequirement.requirementType === "interface" && (
<>
<div className="space-y-2">
<Label htmlFor="req-interface-name"></Label>
<Input
id="req-interface-name"
value={selectedRequirement.interfaceName || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
interfaceName: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-interface-type"></Label>
<Input
id="req-interface-type"
value={selectedRequirement.interfaceType || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
interfaceType: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-data-source"></Label>
<Input
id="req-data-source"
value={selectedRequirement.dataSource || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
dataSource: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-data-destination"></Label>
<Input
id="req-data-destination"
value={selectedRequirement.dataDestination || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
dataDestination: event.target.value,
}))
}
/>
</div>
</>
)}
</div>
)}
</CardContent>

View File

@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { Braces, FolderCog, Wrench } from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
const plannedFeatures = [
{
title: "仓库接入",
description: "后续接入代码仓库、分支与目录范围管理能力。",
icon: FolderCog,
},
{
title: "代码索引",
description: "后续支持代码解析、结构化索引与语义检索。",
icon: Braces,
},
{
title: "能力扩展",
description: "后续补充问答、关联分析与工程化工具链集成。",
icon: Wrench,
},
];
export default function CodeKnowledgeBasePage() {
return (
<DashboardLayout>
<div className="space-y-8">
<div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="max-w-3xl text-muted-foreground">
</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
{plannedFeatures.map((item) => (
<section
key={item.title}
className="space-y-4 rounded-lg border bg-card p-6"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<item.icon className="h-5 w-5" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</section>
))}
</div>
<section className="space-y-4 rounded-lg border border-dashed bg-card p-6">
<div className="space-y-2">
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/dashboard/knowledge/document"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
</Link>
</div>
</section>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FileIcon, defaultStyles } from "react-file-icon";
import {
ArrowRight,
ChevronRight,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
import { api, ApiError } from "@/lib/api";
import { useToast } from "@/components/ui/use-toast";
interface KnowledgeBase {
id: number;
name: string;
description: string;
documents: Document[];
created_at: string;
}
interface Document {
id: number;
file_name: string;
file_path: string;
file_size: number;
content_type: string;
knowledge_base_id: number;
created_at: string;
updated_at: string;
processing_tasks: any[];
}
export default function DocumentKnowledgeBasePage() {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [collapsedDocumentSections, setCollapsedDocumentSections] = useState<
Record<number, boolean>
>({});
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
void fetchKnowledgeBases();
}, []);
const fetchKnowledgeBases = async () => {
try {
const data = await api.get("/api/knowledge-base");
setKnowledgeBases(data);
} catch (error) {
console.error("Failed to fetch knowledge bases:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm("确定要删除这个知识库吗?")) {
return;
}
try {
await api.delete(`/api/knowledge-base/${id}`);
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
toast({
title: "成功",
description: "知识库删除成功",
});
} catch (error) {
console.error("Failed to delete knowledge base:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
}
};
const toggleDocumentsSection = (knowledgeBaseId: number) => {
setCollapsedDocumentSections((prev) => ({
...prev,
[knowledgeBaseId]: !prev[knowledgeBaseId],
}));
};
return (
<DashboardLayout>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Link
href="/dashboard/knowledge/new"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-2 h-4 w-4" />
</Link>
</div>
<div className="grid gap-6">
{knowledgeBases.map((kb) => {
const isDocumentsCollapsed =
collapsedDocumentSections[kb.id] ?? true;
return (
<div
key={kb.id}
className="space-y-4 rounded-lg border bg-card p-6"
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{kb.name}</h3>
<p className="text-sm text-muted-foreground">
{kb.description || "暂无描述"}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{kb.documents.length} ·{" "}
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
<div className="flex space-x-2">
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-secondary"
>
<Settings className="h-4 w-4" />
</Link>
<Link
href={`/dashboard/test-retrieval/${kb.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-secondary"
>
<Search className="h-4 w-4" />
</Link>
<button
onClick={() => void handleDelete(kb.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</div>
{kb.documents.length > 0 && (
<div className="border-t pt-4">
<button
type="button"
onClick={() => toggleDocumentsSection(kb.id)}
className="mb-2 flex w-full items-center justify-between rounded-md px-1 py-1 text-left hover:bg-accent/50"
>
<h4 className="text-sm font-medium"></h4>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{isDocumentsCollapsed ? "展开" : "收起"}</span>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isDocumentsCollapsed ? "" : "rotate-90"
}`}
/>
</div>
</button>
{!isDocumentsCollapsed && (
<div className="flex max-h-[400px] flex-wrap gap-2 overflow-y-auto">
{kb.documents.slice(0, 9).map((doc) => (
<div
key={doc.id}
className="flex h-[150px] w-[150px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border bg-card p-2 transition-colors hover:bg-accent/50"
>
<div className="mb-2 h-8 w-8">
{doc.content_type.toLowerCase().includes("pdf") ? (
<FileIcon extension="pdf" {...defaultStyles.pdf} />
) : doc.content_type.toLowerCase().includes("doc") ? (
<FileIcon extension="doc" {...defaultStyles.docx} />
) : doc.content_type.toLowerCase().includes("txt") ? (
<FileIcon extension="txt" {...defaultStyles.txt} />
) : doc.content_type.toLowerCase().includes("md") ? (
<FileIcon extension="md" {...defaultStyles.md} />
) : (
<FileIcon
extension={doc.file_name.split(".").pop() || ""}
color="#E2E8F0"
labelColor="#94A3B8"
/>
)}
</div>
<div className="max-w-[100px] text-center text-sm font-medium">
<div className="line-clamp-2 overflow-hidden text-ellipsis">
{doc.file_name}
</div>
</div>
<span className="mt-1 text-xs text-muted-foreground">
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
</span>
</div>
))}
{kb.documents.length > 9 && (
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="flex h-[150px] w-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border bg-card p-2 transition-colors hover:bg-accent/50"
>
<div className="mb-2 flex h-8 w-8 items-center justify-center">
<ArrowRight className="h-6 w-6" />
</div>
<span className="text-center text-sm font-medium">
</span>
<span className="mt-1 text-xs text-muted-foreground">
{kb.documents.length}
</span>
</Link>
)}
</div>
)}
</div>
)}
</div>
);
})}
{!loading && knowledgeBases.length === 0 && (
<div className="py-12 text-center">
<p className="text-muted-foreground"></p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="space-y-4">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
<p className="animate-pulse text-muted-foreground">
...
</p>
</div>
</div>
)}
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -1,259 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FileIcon, defaultStyles } from "react-file-icon";
import {
ArrowRight,
ChevronRight,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
import { api, ApiError } from "@/lib/api";
import { useToast } from "@/components/ui/use-toast";
interface KnowledgeBase {
id: number;
name: string;
description: string;
documents: Document[];
created_at: string;
}
interface Document {
id: number;
file_name: string;
file_path: string;
file_size: number;
content_type: string;
knowledge_base_id: number;
created_at: string;
updated_at: string;
processing_tasks: any[];
}
export default function KnowledgeBasePage() {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [collapsedDocumentSections, setCollapsedDocumentSections] = useState<
Record<number, boolean>
>({});
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
fetchKnowledgeBases();
}, []);
const fetchKnowledgeBases = async () => {
try {
const data = await api.get("/api/knowledge-base");
setKnowledgeBases(data);
} catch (error) {
console.error("Failed to fetch knowledge bases:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm("确定要删除这个知识库吗?"))
return;
try {
await api.delete(`/api/knowledge-base/${id}`);
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
toast({
title: "成功",
description: "知识库删除成功",
});
} catch (error) {
console.error("Failed to delete knowledge base:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
}
};
const toggleDocumentsSection = (knowledgeBaseId: number) => {
setCollapsedDocumentSections((prev) => ({
...prev,
[knowledgeBaseId]: !prev[knowledgeBaseId],
}));
};
return (
<DashboardLayout>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold tracking-tight">
</h2>
<p className="text-muted-foreground">
</p>
</div>
<Link
href="/dashboard/knowledge/new"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-2 h-4 w-4" />
</Link>
</div>
<div className="grid gap-6">
{knowledgeBases.map((kb) => {
const isDocumentsCollapsed =
collapsedDocumentSections[kb.id] ?? true;
return (
<div
key={kb.id}
className="rounded-lg border bg-card p-6 space-y-4"
>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">{kb.name}</h3>
<p className="text-sm text-muted-foreground">
{kb.description || "暂无描述"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{kb.documents.length} {" "}
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
<div className="flex space-x-2">
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
>
<Settings className="h-4 w-4" />
</Link>
<Link
href={`/dashboard/test-retrieval/${kb.id}`}
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
>
<Search className="h-4 w-4" />
</Link>
<button
onClick={() => handleDelete(kb.id)}
className="inline-flex items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20 w-8 h-8"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</div>
{kb.documents.length > 0 && (
<div className="border-t pt-4">
<button
type="button"
onClick={() => toggleDocumentsSection(kb.id)}
className="mb-2 flex w-full items-center justify-between rounded-md px-1 py-1 text-left hover:bg-accent/50"
>
<h4 className="text-sm font-medium"></h4>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{isDocumentsCollapsed ? "展开" : "收起"}</span>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isDocumentsCollapsed ? "" : "rotate-90"
}`}
/>
</div>
</button>
{!isDocumentsCollapsed && (
<div className="flex flex-wrap gap-2 max-h-[400px] overflow-y-auto">
{kb.documents.slice(0, 9).map((doc) => (
<div
key={doc.id}
className="flex flex-col items-center gap-2 p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
>
<div className="w-8 h-8 mb-2">
{doc.content_type.toLowerCase().includes("pdf") ? (
<FileIcon extension="pdf" {...defaultStyles.pdf} />
) : doc.content_type.toLowerCase().includes("doc") ? (
<FileIcon extension="doc" {...defaultStyles.docx} />
) : doc.content_type.toLowerCase().includes("txt") ? (
<FileIcon extension="txt" {...defaultStyles.txt} />
) : doc.content_type.toLowerCase().includes("md") ? (
<FileIcon extension="md" {...defaultStyles.md} />
) : (
<FileIcon
extension={doc.file_name.split(".").pop() || ""}
color="#E2E8F0"
labelColor="#94A3B8"
/>
)}
</div>
<div className="text-sm font-medium text-center max-w-[100px]">
<div className="line-clamp-2 overflow-hidden text-ellipsis">
{doc.file_name}
</div>
</div>
<span className="text-xs text-muted-foreground mt-1">
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
</span>
</div>
))}
{kb.documents.length > 9 && (
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="flex flex-col items-center p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
>
<div className="w-8 h-8 mb-2 flex items-center justify-center">
<ArrowRight className="w-6 h-6" />
</div>
<span className="text-sm font-medium text-center">
</span>
<span className="text-xs text-muted-foreground mt-1">
{kb.documents.length}
</span>
</Link>
)}
</div>
)}
</div>
)}
</div>
);
})}
{!loading && knowledgeBases.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">
</p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="space-y-4">
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto"></div>
<p className="text-muted-foreground animate-pulse">
...
</p>
</div>
</div>
)}
</div>
</div>
</DashboardLayout>
);
export default function KnowledgePage() {
redirect("/dashboard/knowledge/document");
}

View File

@@ -99,7 +99,7 @@ export default function DashboardPage() {
</div>
</div>
<a
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="mt-6 flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium"
>
@@ -152,7 +152,7 @@ export default function DashboardPage() {
</a>
<a
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="flex flex-col items-center justify-center rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all hover:border-indigo-500 dark:hover:border-indigo-500"
>
<div className="rounded-full bg-indigo-100 dark:bg-indigo-900/30 p-4 mb-4">
@@ -222,7 +222,7 @@ export default function DashboardPage() {
PDFDOCXMD TXT AI
</p>
<a
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="mt-4 inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 text-sm font-medium"
>

View File

@@ -1,29 +1,32 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import {
Book,
ChevronRight,
FileText,
MessageSquare,
LogOut,
Menu,
MessageSquare,
Search,
User,
} from "lucide-react";
import Breadcrumb from "@/components/ui/breadcrumb";
import { cn } from "@/lib/utils";
type NavigationChild = {
name: string;
href: string;
};
type NavigationItem = {
name: string;
icon: React.ComponentType<{ className?: string }>;
href?: string;
children?: Array<{
name: string;
href: string;
}>;
children?: NavigationChild[];
defaultOpen?: boolean;
};
export default function DashboardLayout({
@@ -34,9 +37,10 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isDocProcessingOpen, setIsDocProcessingOpen] = useState(
pathname.startsWith("/dashboard/doc-processing")
);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
knowledge: pathname.startsWith("/dashboard/knowledge"),
"doc-processing": pathname.startsWith("/dashboard/doc-processing"),
});
useEffect(() => {
const token = localStorage.getItem("token");
@@ -51,13 +55,31 @@ export default function DashboardLayout({
};
useEffect(() => {
if (pathname.startsWith("/dashboard/knowledge")) {
setOpenGroups((prev) => ({ ...prev, knowledge: true }));
}
if (pathname.startsWith("/dashboard/doc-processing")) {
setIsDocProcessingOpen(true);
setOpenGroups((prev) => ({ ...prev, "doc-processing": true }));
}
}, [pathname]);
const toggleGroup = (key: string) => {
setOpenGroups((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const navigation: NavigationItem[] = [
{ name: "知识库", href: "/dashboard/knowledge", icon: Book },
{
name: "知识库",
icon: Book,
defaultOpen: true,
children: [
{ name: "文档知识库", href: "/dashboard/knowledge/document" },
{ name: "代码知识库", href: "/dashboard/knowledge/code" },
],
},
{ name: "对话", href: "/dashboard/chat", icon: MessageSquare },
{
name: "文档处理",
@@ -80,42 +102,39 @@ export default function DashboardLayout({
return (
<div className="min-h-screen bg-background">
{/* Mobile menu button */}
<div className="lg:hidden fixed top-0 left-0 m-4 z-50">
<div className="fixed left-0 top-0 z-50 m-4 lg:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2 rounded-md bg-primary text-primary-foreground"
className="rounded-md bg-primary p-2 text-primary-foreground"
>
<Menu className="h-6 w-6" />
</button>
</div>
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-40 w-64 transform bg-card border-r transition-transform duration-200 ease-in-out lg:translate-x-0 ${
className={`fixed inset-y-0 left-0 z-40 w-64 transform border-r bg-card transition-transform duration-200 ease-in-out lg:translate-x-0 ${
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-full flex-col">
{/* Sidebar header */}
<div className="flex h-16 items-center border-b pl-8">
<Link
href="/dashboard"
className="flex items-center text-lg font-semibold hover:text-primary transition-colors"
className="flex items-center text-lg font-semibold transition-colors hover:text-primary"
>
<img
src="/logo.svg"
alt="标志"
className="w-16 h-16 rounded-lg"
/>
<img src="/logo.svg" alt="标志" className="h-16 w-16 rounded-lg" />
RAG
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-2 px-4 py-6">
{navigation.map((item) => {
if (item.children) {
const groupKey =
item.children[0]?.href.split("/")[2] === "knowledge"
? "knowledge"
: "doc-processing";
const isOpen = openGroups[groupKey] ?? item.defaultOpen ?? false;
const hasActiveChild = item.children.some((child) =>
pathname.startsWith(child.href)
);
@@ -124,7 +143,7 @@ export default function DashboardLayout({
<div key={item.name} className="space-y-2">
<button
type="button"
onClick={() => setIsDocProcessingOpen((prev) => !prev)}
onClick={() => toggleGroup(groupKey)}
className={cn(
"group flex w-full items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200",
hasActiveChild
@@ -136,7 +155,7 @@ export default function DashboardLayout({
className={cn(
"mr-3 h-5 w-5 transition-transform duration-200",
hasActiveChild
? "text-primary scale-110"
? "scale-110 text-primary"
: "group-hover:scale-110"
)}
/>
@@ -144,12 +163,12 @@ export default function DashboardLayout({
<ChevronRight
className={cn(
"ml-auto h-4 w-4 transition-transform duration-200",
isDocProcessingOpen ? "rotate-90" : ""
isOpen && "rotate-90"
)}
/>
</button>
{isDocProcessingOpen && (
{isOpen && (
<div className="ml-7 space-y-1 border-l pl-3">
{item.children.map((child) => {
const isChildActive = pathname.startsWith(child.href);
@@ -188,18 +207,20 @@ export default function DashboardLayout({
<Link
key={item.name}
href={item.href || "/dashboard"}
className={`group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200 ${
className={cn(
"group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200",
isActive
? "bg-gradient-to-r from-primary/10 to-primary/5 text-primary shadow-sm"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground hover:shadow-sm"
}`}
)}
>
<item.icon
className={`mr-3 h-5 w-5 transition-transform duration-200 ${
className={cn(
"mr-3 h-5 w-5 transition-transform duration-200",
isActive
? "text-primary scale-110"
? "scale-110 text-primary"
: "group-hover:scale-110"
}`}
)}
/>
<span className="font-medium">{item.name}</span>
{isActive && (
@@ -209,11 +230,11 @@ export default function DashboardLayout({
);
})}
</nav>
{/* User profile and logout */}
<div className="border-t p-4 space-y-4">
<div className="space-y-4 border-t p-4">
<button
onClick={handleLogout}
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors duration-200"
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-destructive transition-colors duration-200 hover:bg-destructive/10"
>
<LogOut className="mr-3 h-4 w-4" />
退
@@ -222,9 +243,8 @@ export default function DashboardLayout({
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
<main className="min-h-screen py-6 px-4 sm:px-6 lg:px-8">
<main className="min-h-screen px-4 py-6 sm:px-6 lg:px-8">
<Breadcrumb />
{children}
</main>
@@ -237,10 +257,15 @@ export const dashboardConfig = {
mainNav: [],
sidebarNav: [
{
title: "知识库",
href: "/dashboard/knowledge",
title: "文档知识库",
href: "/dashboard/knowledge/document",
icon: "database",
},
{
title: "代码知识库",
href: "/dashboard/knowledge/code",
icon: "braces",
},
{
title: "对话",
href: "/dashboard/chat",

View File

@@ -9,6 +9,8 @@ const Breadcrumb = () => {
const labelMap: Record<string, string> = {
dashboard: "主界面",
knowledge: "知识库",
document: "文档知识库",
code: "代码知识库",
chat: "对话",
"doc-processing": "文档处理",
extract: "需求提取",
@@ -24,11 +26,10 @@ const Breadcrumb = () => {
const generateBreadcrumbs = () => {
const paths = pathname.split("/").filter(Boolean);
const breadcrumbs = paths.map((path, index) => {
return paths.map((path, index) => {
const href = "/" + paths.slice(0, index + 1).join("/");
const label = labelMap[path] || path.replace(/-/g, " ");
const isLast = index === paths.length - 1;
const displayLabel = /^\d+$/.test(path) ? "详情" : label;
return {
@@ -37,8 +38,6 @@ const Breadcrumb = () => {
isLast,
};
});
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs();
@@ -46,25 +45,25 @@ const Breadcrumb = () => {
if (pathname === "/") return null;
return (
<nav className="flex items-center space-x-2 text-base text-muted-foreground mb-6">
<nav className="mb-6 flex items-center space-x-2 text-base text-muted-foreground">
<Link
href="/dashboard"
className="flex items-center hover:text-foreground transition-colors"
className="flex items-center transition-colors hover:text-foreground"
>
<Home className="h-4 w-4" />
</Link>
{breadcrumbs.map((breadcrumb, index) => (
{breadcrumbs.map((breadcrumb) => (
<div key={breadcrumb.href} className="flex items-center">
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/50" />
<ChevronRight className="mx-2 h-4 w-4 text-muted-foreground/50" />
{breadcrumb.isLast ? (
<span className="text-foreground font-medium">
<span className="font-medium text-foreground">
{breadcrumb.label}
</span>
) : (
<Link
href={breadcrumb.href}
className="hover:text-foreground transition-colors"
className="transition-colors hover:text-foreground"
>
{breadcrumb.label}
</Link>

View File

@@ -1,19 +1,36 @@
export type PriorityLevel = "高" | "中" | "低";
export type SeverityLevel = "高" | "中" | "低";
export type RequirementType =
| "functional"
| "interface"
| "performance"
| "security"
| "reliability"
| "other";
export interface RequirementItem {
id: string;
title: string;
title?: string;
description: string;
priority: PriorityLevel;
acceptanceCriteria: string[];
sourceField: string;
sectionUid?: string;
sectionNumber?: string;
sectionTitle?: string;
requirementType?: RequirementType;
interfaceName?: string;
interfaceType?: string;
dataSource?: string;
dataDestination?: string;
sortOrder?: number;
}
export interface RequirementExtractionResult {
documentName: string;
generatedAt: string;
requirements: RequirementItem[];
rawOutput?: Record<string, unknown>;
}
export interface TestCaseItem {
@@ -72,6 +89,52 @@ const asPriority = (value: unknown): PriorityLevel => {
return "中";
};
const asRequirementType = (value: unknown): RequirementType => {
if (
value === "functional" ||
value === "interface" ||
value === "performance" ||
value === "security" ||
value === "reliability" ||
value === "other"
) {
return value;
}
if (value === "接口需求") {
return "interface";
}
if (value === "性能需求") {
return "performance";
}
if (value === "安全需求") {
return "security";
}
if (value === "可靠性需求") {
return "reliability";
}
if (value === "其他需求") {
return "other";
}
return "functional";
};
const summarizeTitle = (value: string, fallbackIndex: number) => {
const source = (value || "").trim();
if (!source) {
return `需求项 ${fallbackIndex + 1}`;
}
for (const separator of ["。", "", "\n", ";", "."]) {
if (source.includes(separator)) {
const first = source.split(separator, 1)[0]?.trim();
if (first) {
return first.slice(0, 20);
}
}
}
return source.slice(0, 20);
};
const asSeverity = (value: unknown): SeverityLevel => {
if (value === "高" || value === "中" || value === "低") {
return value;
@@ -139,7 +202,7 @@ const normalizeRequirementItem = (
title:
typeof item.title === "string" && item.title.trim().length > 0
? item.title
: `未命名需求 ${fallbackIndex + 1}`,
: undefined,
description:
typeof item.description === "string" ? item.description : "",
priority: asPriority(item.priority),
@@ -149,9 +212,227 @@ const normalizeRequirementItem = (
typeof item.sourceField === "string" && item.sourceField.trim().length > 0
? item.sourceField
: `章节 ${fallbackIndex + 1}`,
sectionUid:
typeof item.sectionUid === "string" ? item.sectionUid : undefined,
sectionNumber:
typeof item.sectionNumber === "string" ? item.sectionNumber : undefined,
sectionTitle:
typeof item.sectionTitle === "string" ? item.sectionTitle : undefined,
requirementType: asRequirementType(item.requirementType),
interfaceName:
typeof item.interfaceName === "string" ? item.interfaceName : "",
interfaceType:
typeof item.interfaceType === "string" ? item.interfaceType : "",
dataSource:
typeof item.dataSource === "string" ? item.dataSource : "",
dataDestination:
typeof item.dataDestination === "string" ? item.dataDestination : "",
sortOrder:
typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder)
? item.sortOrder
: fallbackIndex,
};
};
const buildRawOutputFromRequirements = (
requirements: RequirementItem[],
documentName: string,
generatedAt: string
): Record<string, unknown> => {
const sectionMap = new Map<
string,
{
sectionNumber: string;
sectionTitle: string;
list: Array<Record<string, unknown>>;
}
>();
const byType: Record<string, number> = {};
requirements.forEach((req) => {
const number = req.sectionNumber || "";
const title = req.sectionTitle || "未归类章节";
const key = `${number}__${title}`;
if (!sectionMap.has(key)) {
sectionMap.set(key, {
sectionNumber: number,
sectionTitle: title,
list: [],
});
}
const reqType = asRequirementType(req.requirementType);
const typeLabel =
reqType === "interface"
? "接口需求"
: reqType === "performance"
? "性能需求"
: reqType === "security"
? "安全需求"
: reqType === "reliability"
? "可靠性需求"
: reqType === "other"
? "其他需求"
: "功能需求";
byType[typeLabel] = (byType[typeLabel] || 0) + 1;
const reqEntry: Record<string, unknown> = {
需求类型: typeLabel,
需求编号: req.id,
需求描述: req.description,
优先级: req.priority || "中",
};
if (reqType === "interface") {
reqEntry["接口名称"] = req.interfaceName || "";
reqEntry["接口类型"] = req.interfaceType || "";
reqEntry["数据来源"] = req.dataSource || "";
reqEntry["数据目的地"] = req.dataDestination || "";
}
sectionMap.get(key)?.list.push(reqEntry);
});
const content: Record<string, unknown> = {};
for (const section of sectionMap.values()) {
const display = `${section.sectionNumber} ${section.sectionTitle}`.trim();
content[display || "未归类章节"] = {
: {
章节编号: section.sectionNumber,
章节标题: section.sectionTitle,
章节级别: section.sectionNumber
? section.sectionNumber.split(".").length
: 1,
},
需求列表: section.list,
};
}
return {
: {
标题: documentName,
生成时间: generatedAt,
总需求数: requirements.length,
需求类型统计: byType,
},
需求内容: content,
};
};
const parseRawOutputRequirements = (parsed: Record<string, unknown>) => {
const metadata =
parsed["文档元数据"] && typeof parsed["文档元数据"] === "object"
? (parsed["文档元数据"] as Record<string, unknown>)
: {};
const content =
parsed["需求内容"] && typeof parsed["需求内容"] === "object"
? (parsed["需求内容"] as Record<string, unknown>)
: null;
if (!content) {
throw new Error("JSON 中缺少 需求内容 字段");
}
const requirements: RequirementItem[] = [];
const walk = (node: unknown) => {
if (!node || typeof node !== "object") {
return;
}
const sectionNode = node as Record<string, unknown>;
const sectionInfo =
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
? (sectionNode["章节信息"] as Record<string, unknown>)
: {};
const sectionNumber =
typeof sectionInfo["章节编号"] === "string" ? sectionInfo["章节编号"] : "";
const sectionTitle =
typeof sectionInfo["章节标题"] === "string"
? sectionInfo["章节标题"]
: "未归类章节";
const sectionUid =
typeof sectionInfo["章节UID"] === "string" ? sectionInfo["章节UID"] : undefined;
const reqList = Array.isArray(sectionNode["需求列表"])
? (sectionNode["需求列表"] as Array<Record<string, unknown>>)
: [];
reqList.forEach((req, index) => {
const description =
typeof req["需求描述"] === "string" ? req["需求描述"] : "";
const reqType = asRequirementType(req["需求类型"]);
requirements.push(
normalizeRequirementItem(
{
id:
typeof req["需求编号"] === "string" && req["需求编号"].trim().length > 0
? req["需求编号"]
: undefined,
title:
typeof req["需求标题"] === "string" && req["需求标题"].trim().length > 0
? req["需求标题"]
: undefined,
description,
priority: asPriority(req["优先级"]),
acceptanceCriteria: description ? [description] : ["待补充验收标准"],
sourceField: `${sectionNumber} ${sectionTitle}`.trim() || "文档解析",
sectionUid,
sectionNumber,
sectionTitle,
requirementType: reqType,
interfaceName:
reqType === "interface" && typeof req["接口名称"] === "string"
? req["接口名称"]
: "",
interfaceType:
reqType === "interface" && typeof req["接口类型"] === "string"
? req["接口类型"]
: "",
dataSource:
reqType === "interface" && typeof req["数据来源"] === "string"
? req["数据来源"]
: "",
dataDestination:
reqType === "interface" && typeof req["数据目的地"] === "string"
? req["数据目的地"]
: "",
sortOrder: requirements.length + index,
},
requirements.length + index
)
);
});
const children =
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
? (sectionNode["子章节"] as Record<string, unknown>)
: null;
if (!children) {
return;
}
Object.values(children).forEach((child) => walk(child));
};
Object.values(content).forEach((section) => walk(section));
const generatedAt =
typeof metadata["生成时间"] === "string" && metadata["生成时间"].trim().length > 0
? metadata["生成时间"]
: toIso();
const documentName =
typeof metadata["标题"] === "string" && metadata["标题"].trim().length > 0
? metadata["标题"]
: "导入需求文件";
return {
documentName,
generatedAt,
requirements,
rawOutput: parsed,
} as RequirementExtractionResult;
};
export const parseRequirementJson = (
content: string
): RequirementExtractionResult => {
@@ -165,8 +446,18 @@ export const parseRequirementJson = (
documentName?: unknown;
generatedAt?: unknown;
requirements?: unknown;
[key: string]: unknown;
};
if (
value["需求内容"] &&
typeof value["需求内容"] === "object" &&
value["文档元数据"] &&
typeof value["文档元数据"] === "object"
) {
return parseRawOutputRequirements(value as Record<string, unknown>);
}
if (!Array.isArray(value.requirements)) {
throw new Error("JSON 中缺少 requirements 数组字段");
}
@@ -185,6 +476,15 @@ export const parseRequirementJson = (
? value.generatedAt
: toIso(),
requirements,
rawOutput: buildRawOutputFromRequirements(
requirements,
typeof value.documentName === "string" && value.documentName.trim().length > 0
? value.documentName
: "导入需求文件",
typeof value.generatedAt === "string" && value.generatedAt.trim().length > 0
? value.generatedAt
: toIso()
),
};
};
@@ -238,31 +538,34 @@ export const mockGenerateTestCases = async (
): Promise<TestCaseGenerationResult> => {
await wait(WAIT_TIME.generateCases);
const cases: TestCaseItem[] = extraction.requirements.map((item, index) => ({
id: `TC-${String(index + 1).padStart(3, "0")}`,
requirementId: item.id,
requirementTitle: item.title,
title: `${item.title} - 功能验证`,
preconditions: [
"系统已启动并完成账号登录",
"测试数据准备完毕",
"目标模块已开启对应配置",
],
steps: [
`进入 ${item.sourceField} 对应功能页面`,
"输入合法数据并提交",
"观察页面反馈与状态变化",
"触发一次异常输入场景",
"再次执行提交并确认恢复能力",
],
expectedResults: [
"系统返回成功提示且状态更新正确",
"异常场景出现清晰错误提示,不会清空已填内容",
"操作日志可追踪,结果可导出",
],
priority: item.priority,
tags: ["自动生成", "需求映射", item.sourceField],
}));
const cases: TestCaseItem[] = extraction.requirements.map((item, index) => {
const displayTitle = item.title || summarizeTitle(item.description, index);
return {
id: `TC-${String(index + 1).padStart(3, "0")}`,
requirementId: item.id,
requirementTitle: displayTitle,
title: `${displayTitle} - 功能验证`,
preconditions: [
"系统已启动并完成账号登录",
"测试数据准备完毕",
"目标模块已开启对应配置",
],
steps: [
`进入 ${item.sourceField} 对应功能页面`,
"输入合法数据并提交",
"观察页面反馈与状态变化",
"触发一次异常输入场景",
"再次执行提交并确认恢复能力",
],
expectedResults: [
"系统返回成功提示且状态更新正确",
"异常场景出现清晰错误提示,不会清空已填内容",
"操作日志可追踪,结果可导出",
],
priority: item.priority,
tags: ["自动生成", "需求映射", item.sourceField],
};
});
return {
sourceDocument: extraction.documentName,
@@ -406,6 +709,16 @@ export const loadExtractionDraft = () => {
requirements: value.requirements.map((item, index) =>
normalizeRequirementItem(item, index)
),
rawOutput:
value.rawOutput && typeof value.rawOutput === "object"
? value.rawOutput
: buildRawOutputFromRequirements(
value.requirements.map((item, index) =>
normalizeRequirementItem(item, index)
),
value.documentName,
value.generatedAt
),
};
};

View File

@@ -0,0 +1,543 @@
import { RequirementItem, RequirementType } from "@/lib/document-mock";
export interface SectionTreeNode {
key: string;
sectionNumber: string;
sectionTitle: string;
level: number;
requirements: RequirementItem[];
children: SectionTreeNode[];
}
const TYPE_LABEL: Record<RequirementType, string> = {
functional: "功能需求",
interface: "接口需求",
performance: "性能需求",
security: "安全需求",
reliability: "可靠性需求",
other: "其他需求",
};
const LABEL_TYPE: Record<string, RequirementType> = Object.entries(TYPE_LABEL).reduce(
(acc, [type, label]) => {
acc[label] = type as RequirementType;
return acc;
},
{} as Record<string, RequirementType>
);
const asRequirementType = (value: unknown): RequirementType => {
if (
value === "functional" ||
value === "interface" ||
value === "performance" ||
value === "security" ||
value === "reliability" ||
value === "other"
) {
return value;
}
if (typeof value === "string" && LABEL_TYPE[value]) {
return LABEL_TYPE[value];
}
return "functional";
};
const sectionKey = (number?: string, title?: string) =>
`${(number || "").trim()}__${(title || "").trim()}`;
const normalizeSectionNumber = (value?: string) =>
(value || "").replace(/\s+/g, "").trim();
const parseSectionParts = (value?: string): number[] | null => {
const normalized = normalizeSectionNumber(value);
if (!normalized || !/^\d+(?:\.\d+)*$/.test(normalized)) {
return null;
}
return normalized.split(".").map((part) => Number(part));
};
const compareSectionNumbers = (left?: string, right?: string) => {
const leftParts = parseSectionParts(left);
const rightParts = parseSectionParts(right);
if (leftParts && rightParts) {
const len = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < len; index += 1) {
const a = leftParts[index];
const b = rightParts[index];
if (a === undefined) {
return -1;
}
if (b === undefined) {
return 1;
}
if (a !== b) {
return a - b;
}
}
return 0;
}
if (leftParts) {
return -1;
}
if (rightParts) {
return 1;
}
return (left || "").localeCompare(right || "", "zh-CN", { numeric: true });
};
const compareSectionNodes = (left: SectionTreeNode, right: SectionTreeNode) => {
const byNumber = compareSectionNumbers(left.sectionNumber, right.sectionNumber);
if (byNumber !== 0) {
return byNumber;
}
return left.sectionTitle.localeCompare(right.sectionTitle, "zh-CN", {
numeric: true,
});
};
const sortRequirements = (requirements: RequirementItem[]) => {
return [...requirements].sort((left, right) => {
const leftOrder = typeof left.sortOrder === "number" ? left.sortOrder : Number.MAX_SAFE_INTEGER;
const rightOrder = typeof right.sortOrder === "number" ? right.sortOrder : Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.id.localeCompare(right.id, "zh-CN", { numeric: true });
});
};
const sortSectionTree = (nodes: SectionTreeNode[]): SectionTreeNode[] => {
return [...nodes]
.map((node) => ({
...node,
requirements: sortRequirements(node.requirements),
children: sortSectionTree(node.children),
}))
.sort(compareSectionNodes);
};
const flattenSectionTree = (nodes: SectionTreeNode[]): SectionTreeNode[] => {
const result: SectionTreeNode[] = [];
const walk = (node: SectionTreeNode) => {
result.push(node);
node.children.forEach((child) => walk(child));
};
nodes.forEach((node) => walk(node));
return result;
};
const buildSectionTreeWithParents = (sourceNodes: SectionTreeNode[]): SectionTreeNode[] => {
const numbered = new Map<string, SectionTreeNode>();
const fallbackNodes: SectionTreeNode[] = [];
const flattened = flattenSectionTree(sourceNodes);
flattened.forEach((source) => {
const sectionNumber = normalizeSectionNumber(source.sectionNumber);
const normalized: SectionTreeNode = {
...source,
sectionNumber,
requirements: sortRequirements(source.requirements),
children: [],
};
const numberParts = parseSectionParts(sectionNumber);
if (!numberParts) {
fallbackNodes.push(normalized);
return;
}
const existing = numbered.get(sectionNumber);
if (!existing) {
numbered.set(sectionNumber, normalized);
return;
}
existing.requirements = sortRequirements([...existing.requirements, ...normalized.requirements]);
if (!existing.sectionTitle && normalized.sectionTitle) {
existing.sectionTitle = normalized.sectionTitle;
}
if (existing.sectionTitle === "未归类章节" && normalized.sectionTitle) {
existing.sectionTitle = normalized.sectionTitle;
}
});
Array.from(numbered.keys()).forEach((number) => {
const parts = parseSectionParts(number);
if (!parts || parts.length <= 1) {
return;
}
for (let index = 1; index < parts.length; index += 1) {
const parentNumber = parts.slice(0, index).join(".");
if (numbered.has(parentNumber)) {
continue;
}
numbered.set(parentNumber, {
key: `synthetic-${parentNumber}`,
sectionNumber: parentNumber,
sectionTitle: "",
level: index,
requirements: [],
children: [],
});
}
});
const roots: SectionTreeNode[] = [];
const sortedNumbers = Array.from(numbered.keys()).sort(compareSectionNumbers);
sortedNumbers.forEach((number) => {
const node = numbered.get(number);
if (!node) {
return;
}
const parts = parseSectionParts(number);
node.level = parts ? parts.length : node.level;
if (!parts || parts.length <= 1) {
roots.push(node);
return;
}
const parentNumber = parts.slice(0, -1).join(".");
const parent = numbered.get(parentNumber);
if (!parent) {
roots.push(node);
return;
}
parent.children.push(node);
});
const sortedRoots = sortSectionTree(roots);
const sortedFallback = sortSectionTree(fallbackNodes);
return [...sortedRoots, ...sortedFallback];
};
const deepClone = (value: Record<string, unknown>) =>
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
const toRawRequirement = (item: RequirementItem): Record<string, unknown> => {
const reqType = asRequirementType(item.requirementType);
const raw: Record<string, unknown> = {
需求类型: TYPE_LABEL[reqType],
需求编号: item.id,
需求描述: item.description,
优先级: item.priority || "中",
};
if (reqType === "interface") {
raw["接口名称"] = item.interfaceName || "";
raw["接口类型"] = item.interfaceType || "";
raw["数据来源"] = item.dataSource || "";
raw["数据目的地"] = item.dataDestination || "";
}
return raw;
};
const groupRequirementsBySection = (requirements: RequirementItem[]) => {
const grouped = new Map<string, RequirementItem[]>();
requirements.forEach((item) => {
const key = sectionKey(item.sectionNumber, item.sectionTitle);
const list = grouped.get(key) || [];
list.push(item);
grouped.set(key, list);
});
for (const value of grouped.values()) {
value.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
return grouped;
};
const rewriteContentRequirements = (
content: Record<string, unknown>,
grouped: Map<string, RequirementItem[]>
) => {
Object.values(content).forEach((section) => {
if (!section || typeof section !== "object") {
return;
}
const sectionNode = section as Record<string, unknown>;
const sectionInfo =
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
? (sectionNode["章节信息"] as Record<string, unknown>)
: {};
const key = sectionKey(
typeof sectionInfo["章节编号"] === "string" ? sectionInfo["章节编号"] : "",
typeof sectionInfo["章节标题"] === "string" ? sectionInfo["章节标题"] : ""
);
const sectionReqs = grouped.get(key);
if (sectionReqs) {
sectionNode["需求列表"] = sectionReqs.map((item) => toRawRequirement(item));
grouped.delete(key);
} else if (Array.isArray(sectionNode["需求列表"])) {
sectionNode["需求列表"] = [];
}
const children =
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
? (sectionNode["子章节"] as Record<string, unknown>)
: null;
if (children) {
rewriteContentRequirements(children, grouped);
}
});
};
const appendUnmatched = (
content: Record<string, unknown>,
grouped: Map<string, RequirementItem[]>
) => {
if (grouped.size === 0) {
return;
}
let orphan = content["未归类章节"];
if (!orphan || typeof orphan !== "object") {
orphan = {
: {
: "",
: "未归类章节",
章节级别: 1,
},
: [],
};
content["未归类章节"] = orphan;
}
const orphanNode = orphan as Record<string, unknown>;
const orphanReqs = Array.isArray(orphanNode["需求列表"])
? (orphanNode["需求列表"] as Array<Record<string, unknown>>)
: [];
grouped.forEach((items) => {
items.forEach((item) => orphanReqs.push(toRawRequirement(item)));
});
orphanNode["需求列表"] = orphanReqs;
};
const buildFlatRawOutput = (
requirements: RequirementItem[],
documentName: string
): Record<string, unknown> => {
const grouped = groupRequirementsBySection(requirements);
const content: Record<string, unknown> = {};
grouped.forEach((items, key) => {
const [number = "", title = "未归类章节"] = key.split("__");
const display = `${number} ${title}`.trim() || "未归类章节";
content[display] = {
: {
章节编号: number,
章节标题: title || "未归类章节",
章节级别: number ? number.split(".").length : 1,
},
需求列表: items.map((item) => toRawRequirement(item)),
};
});
return {
: {
标题: documentName,
生成时间: new Date().toISOString(),
总需求数: requirements.length,
: {},
},
需求内容: content,
};
};
const refreshMetadata = (
rawOutput: Record<string, unknown>,
requirements: RequirementItem[],
documentName: string
) => {
const metadata =
rawOutput["文档元数据"] && typeof rawOutput["文档元数据"] === "object"
? (rawOutput["文档元数据"] as Record<string, unknown>)
: {};
const typeStats: Record<string, number> = {};
requirements.forEach((item) => {
const reqType = asRequirementType(item.requirementType);
const label = TYPE_LABEL[reqType] || "功能需求";
typeStats[label] = (typeStats[label] || 0) + 1;
});
metadata["标题"] = documentName;
metadata["生成时间"] = new Date().toISOString();
metadata["总需求数"] = requirements.length;
metadata["需求类型统计"] = typeStats;
rawOutput["文档元数据"] = metadata;
};
const toFallbackRequirement = (
req: Record<string, unknown>,
sectionNumber: string,
sectionTitle: string,
order: number
): RequirementItem => {
const requirementType = asRequirementType(req["需求类型"]);
const description = typeof req["需求描述"] === "string" ? req["需求描述"] : "";
return {
id:
typeof req["需求编号"] === "string" && req["需求编号"].trim().length > 0
? req["需求编号"]
: `REQ-${String(order + 1).padStart(3, "0")}`,
description,
priority:
req["优先级"] === "高" || req["优先级"] === "中" || req["优先级"] === "低"
? req["优先级"]
: "中",
acceptanceCriteria: description ? [description] : ["待补充验收标准"],
sourceField: `${sectionNumber} ${sectionTitle}`.trim() || "文档解析",
sectionNumber,
sectionTitle,
requirementType,
interfaceName: typeof req["接口名称"] === "string" ? req["接口名称"] : "",
interfaceType: typeof req["接口类型"] === "string" ? req["接口类型"] : "",
dataSource: typeof req["数据来源"] === "string" ? req["数据来源"] : "",
dataDestination: typeof req["数据目的地"] === "string" ? req["数据目的地"] : "",
sortOrder: order,
};
};
export const rebuildRawOutput = (
rawOutput: Record<string, unknown> | undefined,
requirements: RequirementItem[],
documentName: string
): Record<string, unknown> => {
if (!rawOutput || typeof rawOutput !== "object") {
const fallback = buildFlatRawOutput(requirements, documentName);
refreshMetadata(fallback, requirements, documentName);
return fallback;
}
const cloned = deepClone(rawOutput);
const content =
cloned["需求内容"] && typeof cloned["需求内容"] === "object"
? (cloned["需求内容"] as Record<string, unknown>)
: null;
if (!content) {
const fallback = buildFlatRawOutput(requirements, documentName);
refreshMetadata(fallback, requirements, documentName);
return fallback;
}
const grouped = groupRequirementsBySection(requirements);
rewriteContentRequirements(content, grouped);
appendUnmatched(content, grouped);
refreshMetadata(cloned, requirements, documentName);
return cloned;
};
export const buildSectionTree = (
rawOutput: Record<string, unknown> | undefined,
requirements: RequirementItem[]
): SectionTreeNode[] => {
const byId = new Map(requirements.map((item) => [item.id, item]));
const content =
rawOutput && rawOutput["需求内容"] && typeof rawOutput["需求内容"] === "object"
? (rawOutput["需求内容"] as Record<string, unknown>)
: null;
if (!content) {
const grouped = groupRequirementsBySection(requirements);
const nodes = Array.from(grouped.entries()).map(([key, items], index) => {
const [sectionNumber = "", sectionTitle = "未归类章节"] = key.split("__");
return {
key: `${sectionNumber || "root"}-${index}`,
sectionNumber,
sectionTitle,
level: sectionNumber ? sectionNumber.split(".").length : 1,
requirements: items,
children: [],
};
});
return buildSectionTreeWithParents(nodes);
}
let fallbackOrder = 0;
const walk = (node: unknown, path: string): SectionTreeNode | null => {
if (!node || typeof node !== "object") {
return null;
}
const sectionNode = node as Record<string, unknown>;
const sectionInfo =
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
? (sectionNode["章节信息"] as Record<string, unknown>)
: {};
const sectionNumber =
typeof sectionInfo["章节编号"] === "string"
? normalizeSectionNumber(sectionInfo["章节编号"])
: "";
const sectionTitle =
typeof sectionInfo["章节标题"] === "string"
? sectionInfo["章节标题"]
: "未归类章节";
const level =
typeof sectionInfo["章节级别"] === "number"
? sectionInfo["章节级别"]
: sectionNumber
? sectionNumber.split(".").length
: 1;
const reqList = Array.isArray(sectionNode["需求列表"])
? (sectionNode["需求列表"] as Array<Record<string, unknown>>)
: [];
const mappedRequirements = reqList.map((req) => {
const reqId = typeof req["需求编号"] === "string" ? req["需求编号"] : "";
const current = byId.get(reqId);
if (current) {
return current;
}
const fallback = toFallbackRequirement(req, sectionNumber, sectionTitle, fallbackOrder);
fallbackOrder += 1;
return fallback;
});
const childrenRoot =
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
? (sectionNode["子章节"] as Record<string, unknown>)
: null;
const children = childrenRoot
? Object.entries(childrenRoot)
.map(([name, child]) => walk(child, `${path}/${name}`))
.filter((child): child is SectionTreeNode => Boolean(child))
: [];
if (mappedRequirements.length === 0 && children.length === 0) {
return null;
}
return {
key: `${path}/${sectionNumber}-${sectionTitle}`,
sectionNumber,
sectionTitle,
level,
requirements: mappedRequirements,
children,
};
};
const rawNodes = Object.entries(content)
.map(([name, node]) => walk(node, name))
.filter((node): node is SectionTreeNode => Boolean(node));
return buildSectionTreeWithParents(rawNodes);
};

View File

@@ -19,14 +19,125 @@ export interface SrsResultResponse {
documentName: string;
generatedAt: string;
statistics: Record<string, unknown>;
requirements: Array<
RequirementItem & {
sectionNumber?: string | null;
sectionTitle?: string | null;
requirementType?: string | null;
sortOrder: number;
}
>;
requirements: RequirementItem[];
rawOutput: Record<string, unknown>;
}
export interface SrsHistoryItem {
jobId: number;
documentName: string;
generatedAt: string;
totalRequirements: number;
status: string;
createdAt: string;
updatedAt: string;
}
export interface KnowledgeBaseSummary {
id: number;
name: string;
description?: string | null;
}
export interface TestingPipelineItem {
id: string;
content: string;
}
export interface TestingPipelineCase {
id: string;
item_id: string;
operation_steps: string[];
test_content: string;
expected_result_placeholder: string;
}
export interface TestingPipelineExpectedResult {
id: string;
case_id: string;
result: string;
}
export interface TestingPipelineResponse {
trace_id: string;
requirement_type: string;
reason: string;
candidates: string[];
test_items: Record<string, TestingPipelineItem[]>;
test_cases: Record<string, TestingPipelineCase[]>;
expected_results: Record<string, TestingPipelineExpectedResult[]>;
formatted_output: string;
pipeline_summary: string;
knowledge_used: boolean;
}
export interface TestingGenerationSaveRequest {
source_job_id?: number;
source_document_name: string;
knowledge_base_id?: number;
generated_file: Record<string, unknown>;
}
export interface TestingGenerationJobCreateRequest {
source_job_id?: number;
source_document_name: string;
knowledge_base_id?: number;
requirements: Array<{
id: string;
description: string;
priority: string;
acceptanceCriteria: string[];
sourceField: string;
sectionUid?: string;
sectionNumber?: string;
sectionTitle?: string;
requirementType?: string;
interfaceName?: string;
interfaceType?: string;
dataSource?: string;
dataDestination?: string;
sortOrder: number;
}>;
}
export interface TestingGenerationJobCreateResponse {
job_id: number;
status: string;
}
export interface TestingGenerationJobStatusResponse {
job_id: number;
tool_name: string;
status: "pending" | "processing" | "completed" | "failed";
error_message?: string | null;
started_at?: string | null;
completed_at?: string | null;
source_document_name?: string | null;
current_step?: number | null;
total_steps?: number | null;
current_requirement_id?: string | null;
}
export interface TestingGenerationResult {
jobId: number;
sourceJobId?: number | null;
sourceDocumentName: string;
generatedAt: string;
totalRequirements: number;
knowledgeBaseId?: number | null;
generatedFile: Record<string, unknown>;
}
export interface TestingGenerationHistoryItem {
jobId: number;
sourceJobId?: number | null;
sourceDocumentName: string;
generatedAt: string;
totalRequirements: number;
knowledgeBaseId?: number | null;
status: string;
createdAt: string;
updatedAt: string;
}
export const createSrsJob = async (file: File): Promise<SrsJobCreateResponse> => {
@@ -51,11 +162,18 @@ export const saveSrsRequirements = async (
): Promise<SrsResultResponse> => {
const requirements = extraction.requirements.map((item, index) => ({
id: item.id,
title: item.title,
description: item.description,
priority: item.priority,
acceptanceCriteria: item.acceptanceCriteria,
sourceField: item.sourceField,
sectionUid: item.sectionUid,
sectionNumber: item.sectionNumber,
sectionTitle: item.sectionTitle,
requirementType: item.requirementType,
interfaceName: item.interfaceName,
interfaceType: item.interfaceType,
dataSource: item.dataSource,
dataDestination: item.dataDestination,
sortOrder: index,
}));
@@ -71,5 +189,61 @@ export const toExtractionResult = (
documentName: result.documentName,
generatedAt: result.generatedAt,
requirements: result.requirements,
rawOutput: result.rawOutput,
};
};
export const listSrsHistory = async (): Promise<SrsHistoryItem[]> => {
return api.get("/api/tools/srs/history") as Promise<SrsHistoryItem[]>;
};
export const deleteSrsJob = async (jobId: number): Promise<void> => {
await api.delete(`/api/tools/srs/jobs/${jobId}`);
};
export const listKnowledgeBases = async (): Promise<KnowledgeBaseSummary[]> => {
return api.get("/api/knowledge-base") as Promise<KnowledgeBaseSummary[]>;
};
export const generateTestingContent = async (
requirementText: string,
knowledgeBaseId?: number
): Promise<TestingPipelineResponse> => {
return api.post("/api/testing/generate", {
requirement_text: requirementText,
knowledge_base_ids: knowledgeBaseId ? [knowledgeBaseId] : [],
use_model_generation: true,
}) as Promise<TestingPipelineResponse>;
};
export const createTestingGenerationJob = async (
payload: TestingGenerationJobCreateRequest
): Promise<TestingGenerationJobCreateResponse> => {
return api.post("/api/tools/testing/jobs", payload) as Promise<TestingGenerationJobCreateResponse>;
};
export const getTestingGenerationJobStatus = async (
jobId: number
): Promise<TestingGenerationJobStatusResponse> => {
return api.get(`/api/tools/testing/jobs/${jobId}`) as Promise<TestingGenerationJobStatusResponse>;
};
export const saveTestingGeneration = async (
payload: TestingGenerationSaveRequest
): Promise<TestingGenerationResult> => {
return api.post("/api/tools/testing/generations", payload) as Promise<TestingGenerationResult>;
};
export const listTestingHistory = async (): Promise<TestingGenerationHistoryItem[]> => {
return api.get("/api/tools/testing/history") as Promise<TestingGenerationHistoryItem[]>;
};
export const getTestingGenerationResult = async (
jobId: number
): Promise<TestingGenerationResult> => {
return api.get(`/api/tools/testing/jobs/${jobId}/result`) as Promise<TestingGenerationResult>;
};
export const deleteTestingGeneration = async (jobId: number): Promise<void> => {
await api.delete(`/api/tools/testing/jobs/${jobId}`);
};

View File

@@ -38,6 +38,9 @@ http {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
}
# Frontend
@@ -81,4 +84,4 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
}
}
}