From 7b67f3756e9dbfc635f7c1790ca31475b12a8bc2 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Fri, 20 Feb 2026 00:10:49 +0800 Subject: [PATCH] =?UTF-8?q?Phase=205:=20=E5=AE=9E=E4=BD=93=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E6=89=A9=E5=B1=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库层: - 新增 entity_attributes 表存储自定义属性 - 新增 attribute_templates 表管理属性模板 - 新增 attribute_history 表记录属性变更历史 - 后端 API: - GET/POST /api/v1/projects/{id}/attribute-templates - 属性模板管理 - GET/POST/PUT/DELETE /api/v1/entities/{id}/attributes - 实体属性 CRUD - GET /api/v1/entities/{id}/attributes/history - 属性变更历史 - GET /api/v1/projects/{id}/entities/search-by-attributes - 属性筛选搜索 - 前端 UI: - 实体详情面板添加属性展示 - 属性编辑表单(支持文本、数字、日期、单选、多选) - 属性模板管理界面 - 属性变更历史查看 - 知识库实体卡片显示属性预览 - 属性筛选搜索栏 --- STATUS.md | 24 +- backend/__pycache__/main.cpython-312.pyc | Bin 58168 -> 71711 bytes backend/db_manager.py | 999 +++++++++++++++++++++++ backend/main.py | 363 +++++++- backend/schema.sql | 100 +++ frontend/app.js | 478 ++++++++++- frontend/workbench.html | 272 +++++- 7 files changed, 2209 insertions(+), 27 deletions(-) diff --git a/STATUS.md b/STATUS.md index 78b6549..7d66e1f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -100,7 +100,10 @@ Phase 5: 高级功能 - **进行中 🚧** - 智能项目总结 (全面/高管/技术/风险) - 实体关联路径发现 - 前端推理面板 UI -- [ ] 实体属性扩展 +- [x] 实体属性扩展 ✅ (2026-02-20 完成) + - 数据库层: 新增 `entity_attributes`, `attribute_templates`, `attribute_history` 表 + - 后端 API: 属性模板 CRUD, 实体属性 CRUD, 属性历史查询, 属性筛选搜索 + - 前端 UI: 实体属性管理弹窗, 属性模板管理, 属性变更历史查看, 知识库属性筛选 - [x] 时间线视图 ✅ (2026-02-19 完成) - [ ] 导出功能 - [ ] 协作功能 @@ -120,6 +123,25 @@ Phase 5: 高级功能 - **进行中 🚧** ## 最近更新 +### 2026-02-20 +- 完成 Phase 5 实体属性扩展功能 + - 数据库层: + - 新增 `entity_attributes` 表存储自定义属性 + - 新增 `attribute_templates` 表管理属性模板 + - 新增 `attribute_history` 表记录属性变更历史 + - 后端 API: + - `GET/POST /api/v1/projects/{id}/attribute-templates` - 属性模板管理 + - `GET/POST/PUT/DELETE /api/v1/entities/{id}/attributes` - 实体属性 CRUD + - `GET /api/v1/entities/{id}/attributes/history` - 属性变更历史 + - `GET /api/v1/projects/{id}/entities/search-by-attributes` - 属性筛选搜索 + - 前端 UI: + - 实体详情面板添加属性展示 + - 属性编辑表单(支持文本、数字、日期、单选、多选) + - 属性模板管理界面 + - 属性变更历史查看 + - 知识库实体卡片显示属性预览 + - 属性筛选搜索栏 + ### 2026-02-19 (晚间) - 完成 Phase 5 知识推理与问答增强功能 - 新增 knowledge_reasoner.py 推理引擎 diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index 25b2b6ee353b60317fbae1976be299c207d21ede..96bd3608426e4523e18595c719bafc12fc15577a 100644 GIT binary patch delta 22125 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 K*%A{EkN*qlcLjq0 delta 12704 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 diff --git a/backend/db_manager.py b/backend/db_manager.py index e7229d0..aec803e 100644 --- a/backend/db_manager.py +++ b/backend/db_manager.py @@ -31,10 +31,57 @@ class Entity: definition: str = "" canonical_name: str = "" aliases: List[str] = None + attributes: Dict = None # Phase 5: 实体属性 def __post_init__(self): if self.aliases is None: self.aliases = [] + if self.attributes is None: + self.attributes = {} + +@dataclass +class AttributeTemplate: + """属性模板定义""" + id: str + project_id: str + name: str + type: str # text, number, date, select, multiselect, boolean + options: List[str] = None # 用于 select/multiselect + default_value: str = "" + description: str = "" + is_required: bool = False + display_order: int = 0 + created_at: str = "" + updated_at: str = "" + + def __post_init__(self): + if self.options is None: + self.options = [] + +@dataclass +class EntityAttribute: + """实体属性值""" + id: str + entity_id: str + template_id: str + value: str + template_name: str = "" # 关联查询时填充 + template_type: str = "" # 关联查询时填充 + created_at: str = "" + updated_at: str = "" + +@dataclass +class AttributeHistory: + """属性变更历史""" + id: str + entity_id: str + template_id: str + template_name: str = "" + old_value: str = "" + new_value: str = "" + changed_by: str = "" + changed_at: str = "" + change_reason: str = "" @dataclass class EntityMention: @@ -755,7 +802,372 @@ class DatabaseManager: 'top_entities': [dict(e) for e in entity_stats], 'active_periods': [dict(a) for a in active_periods] } + + # Phase 5: Attribute Template operations + def create_attribute_template(self, project_id: str, template_data: dict) -> dict: + """创建属性模板""" + conn = self.get_conn() + template_id = str(uuid.uuid4())[:8] + now = datetime.now().isoformat() + + conn.execute( + """INSERT INTO attribute_templates + (id, project_id, name, type, description, options, is_required, default_value, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (template_id, project_id, template_data['name'], template_data['type'], + template_data.get('description', ''), + json.dumps(template_data.get('options', [])) if template_data.get('options') else None, + 1 if template_data.get('is_required') else 0, + template_data.get('default_value'), + template_data.get('sort_order', 0), + now, now) + ) + conn.commit() + conn.close() + return self.get_attribute_template(template_id) + def get_attribute_template(self, template_id: str) -> Optional[dict]: + """获取属性模板详情""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", + (template_id,) + ).fetchone() + conn.close() + + if row: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + return data + return None + + def list_attribute_templates(self, project_id: str) -> List[dict]: + """列出项目的所有属性模板""" + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates + WHERE project_id = ? + ORDER BY sort_order, created_at""", + (project_id,) + ).fetchall() + conn.close() + + templates = [] + for row in rows: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + templates.append(data) + return templates + + def update_attribute_template(self, template_id: str, template_data: dict) -> Optional[dict]: + """更新属性模板""" + conn = self.get_conn() + now = datetime.now().isoformat() + + allowed_fields = ['name', 'type', 'description', 'is_required', 'default_value', 'sort_order'] + updates = [] + values = [] + + for field in allowed_fields: + if field in template_data: + updates.append(f"{field} = ?") + if field == 'is_required': + values.append(1 if template_data[field] else 0) + else: + values.append(template_data[field]) + + if 'options' in template_data: + updates.append("options = ?") + values.append(json.dumps(template_data['options']) if template_data['options'] else None) + + if not updates: + conn.close() + return self.get_attribute_template(template_id) + + updates.append("updated_at = ?") + values.append(now) + values.append(template_id) + + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + conn.close() + + return self.get_attribute_template(template_id) + + def delete_attribute_template(self, template_id: str): + """删除属性模板""" + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + conn.commit() + conn.close() + + # Phase 5: Entity Attribute operations + def get_entity_attributes(self, entity_id: str) -> List[dict]: + """获取实体的所有属性""" + conn = self.get_conn() + rows = conn.execute( + """SELECT a.*, t.name as template_name, t.description as template_description + FROM entity_attributes a + LEFT JOIN attribute_templates t ON a.template_id = t.id + WHERE a.entity_id = ? + ORDER BY t.sort_order, a.created_at""", + (entity_id,) + ).fetchall() + conn.close() + + attributes = [] + for row in rows: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + # 解析 value 根据 type + if data['type'] == 'number' and data['value']: + try: + data['value'] = float(data['value']) + except: + pass + elif data['type'] == 'multiselect' and data['value']: + try: + data['value'] = json.loads(data['value']) + except: + pass + attributes.append(data) + return attributes + + def get_entity_attribute(self, attribute_id: str) -> Optional[dict]: + """获取单个属性详情""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM entity_attributes WHERE id = ?", + (attribute_id,) + ).fetchone() + conn.close() + + if row: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + return data + return None + + def set_entity_attribute(self, entity_id: str, attr_data: dict, changed_by: str = "system") -> dict: + """设置实体属性值(创建或更新)""" + conn = self.get_conn() + now = datetime.now().isoformat() + + # 检查是否已存在 + existing = conn.execute( + "SELECT * FROM entity_attributes WHERE entity_id = ? AND name = ?", + (entity_id, attr_data['name']) + ).fetchone() + + # 处理 value 存储 + value = attr_data['value'] + if attr_data['type'] == 'multiselect' and isinstance(value, list): + value = json.dumps(value) + elif value is not None: + value = str(value) + + if existing: + # 记录历史 + old_value = existing['value'] + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, attr_data['name'], old_value, value, + changed_by, now, attr_data.get('change_reason', '')) + ) + + # 更新属性 + conn.execute( + """UPDATE entity_attributes + SET value = ?, type = ?, options = ?, updated_at = ? + WHERE id = ?""", + (value, attr_data['type'], + json.dumps(attr_data.get('options', [])) if attr_data.get('options') else existing['options'], + now, existing['id']) + ) + attribute_id = existing['id'] + else: + # 创建新属性 + attribute_id = str(uuid.uuid4())[:8] + conn.execute( + """INSERT INTO entity_attributes + (id, entity_id, template_id, name, type, value, options, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (attribute_id, entity_id, attr_data.get('template_id'), + attr_data['name'], attr_data['type'], value, + json.dumps(attr_data.get('options', [])) if attr_data.get('options') else None, + now, now) + ) + + # 记录历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, attr_data['name'], None, value, + changed_by, now, attr_data.get('change_reason', '创建属性')) + ) + + conn.commit() + conn.close() + return self.get_entity_attribute(attribute_id) + + def update_entity_attribute(self, attribute_id: str, attr_data: dict, changed_by: str = "system") -> Optional[dict]: + """更新实体属性""" + conn = self.get_conn() + now = datetime.now().isoformat() + + existing = conn.execute( + "SELECT * FROM entity_attributes WHERE id = ?", + (attribute_id,) + ).fetchone() + + if not existing: + conn.close() + return None + + # 处理 value + value = attr_data.get('value') + if value is not None: + if attr_data.get('type', existing['type']) == 'multiselect' and isinstance(value, list): + value = json.dumps(value) + else: + value = str(value) + + # 记录历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], existing['entity_id'], existing['name'], + existing['value'], value, changed_by, now, + attr_data.get('change_reason', '')) + ) + + # 更新字段 + updates = [] + values = [] + + if 'value' in attr_data: + updates.append("value = ?") + values.append(value) + + if 'type' in attr_data: + updates.append("type = ?") + values.append(attr_data['type']) + + if 'options' in attr_data: + updates.append("options = ?") + values.append(json.dumps(attr_data['options']) if attr_data['options'] else None) + + if updates: + updates.append("updated_at = ?") + values.append(now) + values.append(attribute_id) + + query = f"UPDATE entity_attributes SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + + conn.close() + return self.get_entity_attribute(attribute_id) + + def delete_entity_attribute(self, attribute_id: str, changed_by: str = "system"): + """删除实体属性""" + conn = self.get_conn() + + existing = conn.execute( + "SELECT * FROM entity_attributes WHERE id = ?", + (attribute_id,) + ).fetchone() + + if existing: + # 记录历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], existing['entity_id'], existing['name'], + existing['value'], None, changed_by, datetime.now().isoformat(), '删除属性') + ) + + conn.execute("DELETE FROM entity_attributes WHERE id = ?", (attribute_id,)) + conn.commit() + + conn.close() + + def get_attribute_history(self, entity_id: str, attribute_name: str = None) -> List[dict]: + """获取属性变更历史""" + conn = self.get_conn() + + if attribute_name: + rows = conn.execute( + """SELECT * FROM attribute_history + WHERE entity_id = ? AND attribute_name = ? + ORDER BY changed_at DESC""", + (entity_id, attribute_name) + ).fetchall() + else: + rows = conn.execute( + """SELECT * FROM attribute_history + WHERE entity_id = ? + ORDER BY changed_at DESC""", + (entity_id,) + ).fetchall() + + conn.close() + return [dict(r) for r in rows] + + def search_entities_by_attributes(self, project_id: str, filters: List[dict]) -> List[Entity]: + """根据属性筛选搜索实体""" + conn = self.get_conn() + + # 基础查询 + base_query = "SELECT DISTINCT e.* FROM entities e" + where_conditions = ["e.project_id = ?"] + params = [project_id] + + # 为每个过滤条件添加 JOIN + join_clauses = [] + for i, f in enumerate(filters): + alias = f"a{i}" + join_clauses.append( + f"JOIN entity_attributes {alias} ON e.id = {alias}.entity_id AND {alias}.name = ?" + ) + params.append(f['name']) + + operator = f.get('operator', 'eq') + if operator == 'eq': + where_conditions.append(f"{alias}.value = ?") + params.append(str(f['value'])) + elif operator == 'contains': + where_conditions.append(f"{alias}.value LIKE ?") + params.append(f"%{f['value']}%") + elif operator == 'gt': + where_conditions.append(f"CAST({alias}.value AS REAL) > ?") + params.append(float(f['value'])) + elif operator == 'lt': + where_conditions.append(f"CAST({alias}.value AS REAL) < ?") + params.append(float(f['value'])) + + query = base_query + " " + " ".join(join_clauses) + " WHERE " + " AND ".join(where_conditions) + + rows = conn.execute(query, params).fetchall() + conn.close() + + entities = [] + for row in rows: + data = dict(row) + data['aliases'] = json.loads(data['aliases']) if data['aliases'] else [] + entities.append(Entity(**data)) + return entities + def get_transcript_context(self, transcript_id: str, position: int, context_chars: int = 200) -> str: """获取转录文本的上下文""" conn = self.get_conn() @@ -773,6 +1185,593 @@ class DatabaseManager: end = min(len(text), position + context_chars) return text[start:end] + # ==================== Phase 5: 实体属性管理 ==================== + + # ---- 属性模板管理 ---- + + def create_attribute_template(self, template: AttributeTemplate) -> AttributeTemplate: + """创建属性模板""" + conn = self.get_conn() + now = datetime.now().isoformat() + conn.execute( + """INSERT INTO attribute_templates + (id, project_id, name, type, options, default_value, description, is_required, display_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (template.id, template.project_id, template.name, template.type, + json.dumps(template.options) if template.options else None, + template.default_value, template.description, template.is_required, + template.display_order, now, now) + ) + conn.commit() + conn.close() + return template + + def get_attribute_template(self, template_id: str) -> Optional[AttributeTemplate]: + """获取属性模板""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", + (template_id,) + ).fetchone() + conn.close() + if row: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + return AttributeTemplate(**data) + return None + + def list_attribute_templates(self, project_id: str) -> List[AttributeTemplate]: + """列出项目的所有属性模板""" + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates + WHERE project_id = ? + ORDER BY display_order, created_at""", + (project_id,) + ).fetchall() + conn.close() + + templates = [] + for row in rows: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + templates.append(AttributeTemplate(**data)) + return templates + + def update_attribute_template(self, template_id: str, **kwargs) -> Optional[AttributeTemplate]: + """更新属性模板""" + conn = self.get_conn() + + allowed_fields = ['name', 'type', 'options', 'default_value', + 'description', 'is_required', 'display_order'] + updates = [] + values = [] + + for field in allowed_fields: + if field in kwargs: + updates.append(f"{field} = ?") + if field == 'options': + values.append(json.dumps(kwargs[field]) if kwargs[field] else None) + else: + values.append(kwargs[field]) + + if not updates: + conn.close() + return self.get_attribute_template(template_id) + + updates.append("updated_at = ?") + values.append(datetime.now().isoformat()) + values.append(template_id) + + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + conn.close() + + return self.get_attribute_template(template_id) + + def delete_attribute_template(self, template_id: str): + """删除属性模板(会级联删除相关属性值)""" + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + conn.commit() + conn.close() + + # ---- 实体属性值管理 ---- + + def set_entity_attribute(self, attr: EntityAttribute, + changed_by: str = "system", + change_reason: str = "") -> EntityAttribute: + """设置实体属性值,自动记录历史""" + conn = self.get_conn() + now = datetime.now().isoformat() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (attr.entity_id, attr.template_id) + ).fetchone() + old_value = old_row['value'] if old_row else None + + # 记录变更历史 + if old_value != attr.value: + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], attr.entity_id, attr.template_id, + old_value, attr.value, changed_by, now, change_reason) + ) + + # 插入或更新属性值 + conn.execute( + """INSERT OR REPLACE INTO entity_attributes + (id, entity_id, template_id, value, created_at, updated_at) + VALUES ( + COALESCE((SELECT id FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ?, ?, ?, + COALESCE((SELECT created_at FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ? + )""", + (attr.entity_id, attr.template_id, attr.id, + attr.entity_id, attr.template_id, attr.value, + attr.entity_id, attr.template_id, now, now) + ) + + conn.commit() + conn.close() + return attr + + def get_entity_attributes(self, entity_id: str) -> List[EntityAttribute]: + """获取实体的所有属性值""" + conn = self.get_conn() + rows = conn.execute( + """SELECT ea.*, at.name as template_name, at.type as template_type + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id = ? + ORDER BY at.display_order""", + (entity_id,) + ).fetchall() + conn.close() + + return [EntityAttribute(**dict(r)) for r in rows] + + def get_entity_with_attributes(self, entity_id: str) -> Optional[Entity]: + """获取实体详情,包含属性""" + entity = self.get_entity(entity_id) + if not entity: + return None + + # 获取属性 + attrs = self.get_entity_attributes(entity_id) + entity.attributes = { + attr.template_name: { + 'value': attr.value, + 'type': attr.template_type, + 'template_id': attr.template_id + } + for attr in attrs + } + + return entity + + def delete_entity_attribute(self, entity_id: str, template_id: str, + changed_by: str = "system", change_reason: str = ""): + """删除实体属性值""" + conn = self.get_conn() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ).fetchone() + + if old_row: + # 记录删除历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, template_id, + old_row['value'], None, changed_by, datetime.now().isoformat(), + change_reason or "属性删除") + ) + + # 删除属性 + conn.execute( + "DELETE FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ) + conn.commit() + + conn.close() + + # ---- 属性历史管理 ---- + + def get_attribute_history(self, entity_id: str = None, + template_id: str = None, + limit: int = 50) -> List[AttributeHistory]: + """获取属性变更历史""" + conn = self.get_conn() + + conditions = [] + params = [] + + if entity_id: + conditions.append("ah.entity_id = ?") + params.append(entity_id) + + if template_id: + conditions.append("ah.template_id = ?") + params.append(template_id) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + rows = conn.execute( + f"""SELECT ah.*, at.name as template_name + FROM attribute_history ah + JOIN attribute_templates at ON ah.template_id = at.id + WHERE {where_clause} + ORDER BY ah.changed_at DESC + LIMIT ?""", + params + [limit] + ).fetchall() + conn.close() + + return [AttributeHistory(**dict(r)) for r in rows] + + def search_entities_by_attributes(self, project_id: str, + attribute_filters: Dict[str, str]) -> List[Entity]: + """根据属性筛选搜索实体""" + conn = self.get_conn() + + # 获取项目所有实体 + entities = self.list_project_entities(project_id) + + if not attribute_filters: + return entities + + # 获取所有实体的属性 + entity_ids = [e.id for e in entities] + if not entity_ids: + return [] + + # 构建查询条件 + placeholders = ','.join(['?' for _ in entity_ids]) + rows = conn.execute( + f"""SELECT ea.*, at.name as template_name + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id IN ({placeholders})""", + entity_ids + ).fetchall() + + conn.close() + + # 按实体ID分组属性 + entity_attrs = {} + for row in rows: + eid = row['entity_id'] + if eid not in entity_attrs: + entity_attrs[eid] = {} + entity_attrs[eid][row['template_name']] = row['value'] + + # 过滤实体 + filtered = [] + for entity in entities: + attrs = entity_attrs.get(entity.id, {}) + match = True + for attr_name, attr_value in attribute_filters.items(): + if attrs.get(attr_name) != attr_value: + match = False + break + if match: + entity.attributes = attrs + filtered.append(entity) + + return filtered + + +# Singleton instance +_db_manager = None + + +def get_db_manager() -> DatabaseManager: + global _db_manager + if _db_manager is None: + _db_manager = DatabaseManager() + return _db_manager + end = min(len(text), position + context_chars) + return text[start:end] + + # ==================== Phase 5: 实体属性管理 ==================== + + # ---- 属性模板管理 ---- + + def create_attribute_template(self, template: AttributeTemplate) -> AttributeTemplate: + """创建属性模板""" + conn = self.get_conn() + now = datetime.now().isoformat() + conn.execute( + """INSERT INTO attribute_templates + (id, project_id, name, type, options, default_value, description, is_required, display_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (template.id, template.project_id, template.name, template.type, + json.dumps(template.options) if template.options else None, + template.default_value, template.description, template.is_required, + template.display_order, now, now) + ) + conn.commit() + conn.close() + return template + + def get_attribute_template(self, template_id: str) -> Optional[AttributeTemplate]: + """获取属性模板""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", + (template_id,) + ).fetchone() + conn.close() + if row: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + return AttributeTemplate(**data) + return None + + def list_attribute_templates(self, project_id: str) -> List[AttributeTemplate]: + """列出项目的所有属性模板""" + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates + WHERE project_id = ? + ORDER BY display_order, created_at""", + (project_id,) + ).fetchall() + conn.close() + + templates = [] + for row in rows: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + templates.append(AttributeTemplate(**data)) + return templates + + def update_attribute_template(self, template_id: str, **kwargs) -> Optional[AttributeTemplate]: + """更新属性模板""" + conn = self.get_conn() + + allowed_fields = ['name', 'type', 'options', 'default_value', + 'description', 'is_required', 'display_order'] + updates = [] + values = [] + + for field in allowed_fields: + if field in kwargs: + updates.append(f"{field} = ?") + if field == 'options': + values.append(json.dumps(kwargs[field]) if kwargs[field] else None) + else: + values.append(kwargs[field]) + + if not updates: + conn.close() + return self.get_attribute_template(template_id) + + updates.append("updated_at = ?") + values.append(datetime.now().isoformat()) + values.append(template_id) + + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + conn.close() + + return self.get_attribute_template(template_id) + + def delete_attribute_template(self, template_id: str): + """删除属性模板(会级联删除相关属性值)""" + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + conn.commit() + conn.close() + + # ---- 实体属性值管理 ---- + + def set_entity_attribute(self, attr: EntityAttribute, + changed_by: str = "system", + change_reason: str = "") -> EntityAttribute: + """设置实体属性值,自动记录历史""" + conn = self.get_conn() + now = datetime.now().isoformat() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (attr.entity_id, attr.template_id) + ).fetchone() + old_value = old_row['value'] if old_row else None + + # 记录变更历史 + if old_value != attr.value: + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], attr.entity_id, attr.template_id, + old_value, attr.value, changed_by, now, change_reason) + ) + + # 插入或更新属性值 + conn.execute( + """INSERT OR REPLACE INTO entity_attributes + (id, entity_id, template_id, value, created_at, updated_at) + VALUES ( + COALESCE((SELECT id FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ?, ?, ?, + COALESCE((SELECT created_at FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ? + )""", + (attr.entity_id, attr.template_id, attr.id, + attr.entity_id, attr.template_id, attr.value, + attr.entity_id, attr.template_id, now, now) + ) + + conn.commit() + conn.close() + return attr + + def get_entity_attributes(self, entity_id: str) -> List[EntityAttribute]: + """获取实体的所有属性值""" + conn = self.get_conn() + rows = conn.execute( + """SELECT ea.*, at.name as template_name, at.type as template_type + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id = ? + ORDER BY at.display_order""", + (entity_id,) + ).fetchall() + conn.close() + + return [EntityAttribute(**dict(r)) for r in rows] + + def get_entity_with_attributes(self, entity_id: str) -> Optional[Entity]: + """获取实体详情,包含属性""" + entity = self.get_entity(entity_id) + if not entity: + return None + + # 获取属性 + attrs = self.get_entity_attributes(entity_id) + entity.attributes = { + attr.template_name: { + 'value': attr.value, + 'type': attr.template_type, + 'template_id': attr.template_id + } + for attr in attrs + } + + return entity + + def delete_entity_attribute(self, entity_id: str, template_id: str, + changed_by: str = "system", change_reason: str = ""): + """删除实体属性值""" + conn = self.get_conn() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ).fetchone() + + if old_row: + # 记录删除历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, template_id, + old_row['value'], None, changed_by, datetime.now().isoformat(), + change_reason or "属性删除") + ) + + # 删除属性 + conn.execute( + "DELETE FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ) + conn.commit() + + conn.close() + + # ---- 属性历史管理 ---- + + def get_attribute_history(self, entity_id: str = None, + template_id: str = None, + limit: int = 50) -> List[AttributeHistory]: + """获取属性变更历史""" + conn = self.get_conn() + + conditions = [] + params = [] + + if entity_id: + conditions.append("ah.entity_id = ?") + params.append(entity_id) + + if template_id: + conditions.append("ah.template_id = ?") + params.append(template_id) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + rows = conn.execute( + f"""SELECT ah.*, at.name as template_name + FROM attribute_history ah + JOIN attribute_templates at ON ah.template_id = at.id + WHERE {where_clause} + ORDER BY ah.changed_at DESC + LIMIT ?""", + params + [limit] + ).fetchall() + conn.close() + + return [AttributeHistory(**dict(r)) for r in rows] + + def search_entities_by_attributes(self, project_id: str, + attribute_filters: Dict[str, str]) -> List[Entity]: + """根据属性筛选搜索实体""" + conn = self.get_conn() + + # 获取项目所有实体 + entities = self.list_project_entities(project_id) + + if not attribute_filters: + return entities + + # 获取所有实体的属性 + entity_ids = [e.id for e in entities] + if not entity_ids: + return [] + + # 构建查询条件 + placeholders = ','.join(['?' for _ in entity_ids]) + rows = conn.execute( + f"""SELECT ea.*, at.name as template_name + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id IN ({placeholders})""", + entity_ids + ).fetchall() + + conn.close() + + # 按实体ID分组属性 + entity_attrs = {} + for row in rows: + eid = row['entity_id'] + if eid not in entity_attrs: + entity_attrs[eid] = {} + entity_attrs[eid][row['template_name']] = row['value'] + + # 过滤实体 + filtered = [] + for entity in entities: + attrs = entity_attrs.get(entity.id, {}) + match = True + for attr_name, attr_value in attribute_filters.items(): + if attrs.get(attr_name) != attr_value: + match = False + break + if match: + entity.attributes = attrs + filtered.append(entity) + + return filtered + # Singleton instance _db_manager = None diff --git a/backend/main.py b/backend/main.py index efa664c..f3bd441 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,7 +15,7 @@ from fastapi import FastAPI, File, UploadFile, HTTPException, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from typing import List, Optional +from typing import List, Optional, Union from datetime import datetime # Add backend directory to path for imports @@ -755,14 +755,18 @@ async def get_knowledge_base(project_id: str): # 获取术语表 glossary = db.list_glossary(project_id) - # 构建实体统计 + # 构建实体统计和属性 entity_stats = {} + entity_attributes = {} for ent in entities: mentions = db.get_entity_mentions(ent.id) entity_stats[ent.id] = { "mention_count": len(mentions), "transcript_ids": list(set([m.transcript_id for m in mentions])) } + # Phase 5: 获取实体属性 + attrs = db.get_entity_attributes(ent.id) + entity_attributes[ent.id] = attrs # 构建实体名称映射 entity_map = {e.id: e.name for e in entities} @@ -787,7 +791,8 @@ async def get_knowledge_base(project_id: str): "definition": e.definition, "aliases": e.aliases, "mention_count": entity_stats.get(e.id, {}).get("mention_count", 0), - "appears_in": entity_stats.get(e.id, {}).get("transcript_ids", []) + "appears_in": entity_stats.get(e.id, {}).get("transcript_ids", []), + "attributes": entity_attributes.get(e.id, []) # Phase 5: 包含属性 } for e in entities ], @@ -1498,9 +1503,361 @@ async def project_summary(project_id: str, req: SummaryRequest): "project_id": project_id, "summary_type": req.summary_type, **summary + **summary } +# ==================== Phase 5: 实体属性扩展 API ==================== + +class AttributeTemplateCreate(BaseModel): + name: str + type: str # text, number, date, select, multiselect, boolean + options: Optional[List[str]] = None + default_value: Optional[str] = "" + description: Optional[str] = "" + is_required: bool = False + display_order: int = 0 + + +class AttributeTemplateUpdate(BaseModel): + name: Optional[str] = None + type: Optional[str] = None + options: Optional[List[str]] = None + default_value: Optional[str] = None + description: Optional[str] = None + is_required: Optional[bool] = None + display_order: Optional[int] = None + + +class EntityAttributeSet(BaseModel): + template_id: str + value: str + change_reason: Optional[str] = "" + + +class EntityAttributeBatchSet(BaseModel): + attributes: List[EntityAttributeSet] + change_reason: Optional[str] = "" + + +# 属性模板管理 API +@app.post("/api/v1/projects/{project_id}/attribute-templates") +async def create_attribute_template_endpoint(project_id: str, template: AttributeTemplateCreate): + """创建属性模板""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + from db_manager import AttributeTemplate + + db = get_db_manager() + project = db.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + new_template = AttributeTemplate( + id=str(uuid.uuid4())[:8], + project_id=project_id, + name=template.name, + type=template.type, + options=template.options or [], + default_value=template.default_value or "", + description=template.description or "", + is_required=template.is_required, + display_order=template.display_order + ) + + db.create_attribute_template(new_template) + + return { + "id": new_template.id, + "name": new_template.name, + "type": new_template.type, + "success": True + } + + +@app.get("/api/v1/projects/{project_id}/attribute-templates") +async def list_attribute_templates_endpoint(project_id: str): + """列出项目的所有属性模板""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + templates = db.list_attribute_templates(project_id) + + return [ + { + "id": t.id, + "name": t.name, + "type": t.type, + "options": t.options, + "default_value": t.default_value, + "description": t.description, + "is_required": t.is_required, + "display_order": t.display_order + } + for t in templates + ] + + +@app.get("/api/v1/attribute-templates/{template_id}") +async def get_attribute_template_endpoint(template_id: str): + """获取属性模板详情""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + template = db.get_attribute_template(template_id) + + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + return { + "id": template.id, + "name": template.name, + "type": template.type, + "options": template.options, + "default_value": template.default_value, + "description": template.description, + "is_required": template.is_required, + "display_order": template.display_order + } + + +@app.put("/api/v1/attribute-templates/{template_id}") +async def update_attribute_template_endpoint(template_id: str, update: AttributeTemplateUpdate): + """更新属性模板""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + template = db.get_attribute_template(template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + update_data = {k: v for k, v in update.dict().items() if v is not None} + updated = db.update_attribute_template(template_id, **update_data) + + return { + "id": updated.id, + "name": updated.name, + "type": updated.type, + "success": True + } + + +@app.delete("/api/v1/attribute-templates/{template_id}") +async def delete_attribute_template_endpoint(template_id: str): + """删除属性模板""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + db.delete_attribute_template(template_id) + + return {"success": True, "message": f"Template {template_id} deleted"} + + +# 实体属性值管理 API +@app.post("/api/v1/entities/{entity_id}/attributes") +async def set_entity_attribute_endpoint(entity_id: str, attr: EntityAttributeSet): + """设置实体属性值""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + from db_manager import EntityAttribute + + db = get_db_manager() + entity = db.get_entity(entity_id) + if not entity: + raise HTTPException(status_code=404, detail="Entity not found") + + # 验证模板存在 + template = db.get_attribute_template(attr.template_id) + if not template: + raise HTTPException(status_code=404, detail="Attribute template not found") + + new_attr = EntityAttribute( + id=str(uuid.uuid4())[:8], + entity_id=entity_id, + template_id=attr.template_id, + value=attr.value + ) + + db.set_entity_attribute(new_attr, changed_by="user", change_reason=attr.change_reason) + + return { + "entity_id": entity_id, + "template_id": attr.template_id, + "template_name": template.name, + "value": attr.value, + "success": True + } + + +@app.post("/api/v1/entities/{entity_id}/attributes/batch") +async def batch_set_entity_attributes_endpoint(entity_id: str, batch: EntityAttributeBatchSet): + """批量设置实体属性值""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + from db_manager import EntityAttribute + + db = get_db_manager() + entity = db.get_entity(entity_id) + if not entity: + raise HTTPException(status_code=404, detail="Entity not found") + + results = [] + for attr_data in batch.attributes: + template = db.get_attribute_template(attr_data.template_id) + if template: + new_attr = EntityAttribute( + id=str(uuid.uuid4())[:8], + entity_id=entity_id, + template_id=attr_data.template_id, + value=attr_data.value + ) + db.set_entity_attribute(new_attr, changed_by="user", + change_reason=batch.change_reason or "批量更新") + results.append({ + "template_id": attr_data.template_id, + "template_name": template.name, + "value": attr_data.value + }) + + return { + "entity_id": entity_id, + "updated_count": len(results), + "attributes": results, + "success": True + } + + +@app.get("/api/v1/entities/{entity_id}/attributes") +async def get_entity_attributes_endpoint(entity_id: str): + """获取实体的所有属性值""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + entity = db.get_entity(entity_id) + if not entity: + raise HTTPException(status_code=404, detail="Entity not found") + + attrs = db.get_entity_attributes(entity_id) + + return [ + { + "id": a.id, + "template_id": a.template_id, + "template_name": a.template_name, + "template_type": a.template_type, + "value": a.value + } + for a in attrs + ] + + +@app.delete("/api/v1/entities/{entity_id}/attributes/{template_id}") +async def delete_entity_attribute_endpoint(entity_id: str, template_id: str, + reason: Optional[str] = ""): + """删除实体属性值""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + db.delete_entity_attribute(entity_id, template_id, + changed_by="user", change_reason=reason) + + return {"success": True, "message": "Attribute deleted"} + + +# 属性历史 API +@app.get("/api/v1/entities/{entity_id}/attributes/history") +async def get_entity_attribute_history_endpoint(entity_id: str, limit: int = 50): + """获取实体的属性变更历史""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + history = db.get_attribute_history(entity_id=entity_id, limit=limit) + + return [ + { + "id": h.id, + "template_id": h.template_id, + "template_name": h.template_name, + "old_value": h.old_value, + "new_value": h.new_value, + "changed_by": h.changed_by, + "changed_at": h.changed_at, + "change_reason": h.change_reason + } + for h in history + ] + + +@app.get("/api/v1/attribute-templates/{template_id}/history") +async def get_template_history_endpoint(template_id: str, limit: int = 50): + """获取属性模板的所有变更历史(跨实体)""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + history = db.get_attribute_history(template_id=template_id, limit=limit) + + return [ + { + "id": h.id, + "entity_id": h.entity_id, + "template_name": h.template_name, + "old_value": h.old_value, + "new_value": h.new_value, + "changed_by": h.changed_by, + "changed_at": h.changed_at, + "change_reason": h.change_reason + } + for h in history + ] + + +# 属性筛选搜索 API +@app.get("/api/v1/projects/{project_id}/entities/search-by-attributes") +async def search_entities_by_attributes_endpoint( + project_id: str, + attribute_filter: Optional[str] = None # JSON 格式: {"职位": "经理", "部门": "技术部"} +): + """根据属性筛选搜索实体""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + project = db.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + filters = {} + if attribute_filter: + try: + filters = json.loads(attribute_filter) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid attribute_filter JSON") + + entities = db.search_entities_by_attributes(project_id, filters) + + return [ + { + "id": e.id, + "name": e.name, + "type": e.type, + "definition": e.definition, + "attributes": e.attributes + } + for e in entities + ] + + # Serve frontend - MUST be last to not override API routes app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend") diff --git a/backend/schema.sql b/backend/schema.sql index 7cc1a92..f614676 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -75,6 +75,94 @@ CREATE TABLE IF NOT EXISTS glossary ( FOREIGN KEY (project_id) REFERENCES projects(id) ); +-- Phase 5: 属性模板表 +CREATE TABLE IF NOT EXISTS attribute_templates ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, -- text/number/date/select/multiselect + description TEXT, + options TEXT, -- JSON 数组,用于 select/multiselect 类型 + is_required INTEGER DEFAULT 0, + default_value TEXT, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) +); + +-- Phase 5: 实体属性值表 +CREATE TABLE IF NOT EXISTS entity_attributes ( + id TEXT PRIMARY KEY, + entity_id TEXT NOT NULL, + template_id TEXT, + name TEXT NOT NULL, + type TEXT NOT NULL, -- text/number/date/select/multiselect + value TEXT, -- 存储实际值 + options TEXT, -- JSON 数组,用于 select/multiselect + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES attribute_templates(id) ON DELETE SET NULL, + UNIQUE(entity_id, name) +); + +-- Phase 5: 属性变更历史表 +CREATE TABLE IF NOT EXISTS attribute_history ( + id TEXT PRIMARY KEY, + entity_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, -- 用户ID或系统 + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + change_reason TEXT, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE +); + +-- Phase 5: 属性模板表(项目级自定义属性定义) +CREATE TABLE IF NOT EXISTS attribute_templates ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, -- 属性名称,如"年龄"、"职位" + type TEXT NOT NULL, -- 属性类型: text, number, date, select, multiselect, boolean + options TEXT, -- JSON 数组,用于 select/multiselect 类型 + default_value TEXT, -- 默认值 + description TEXT, -- 属性描述 + is_required BOOLEAN DEFAULT 0, -- 是否必填 + display_order INTEGER DEFAULT 0, -- 显示顺序 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) +); + +-- Phase 5: 实体属性值表 +CREATE TABLE IF NOT EXISTS entity_attributes ( + id TEXT PRIMARY KEY, + entity_id TEXT NOT NULL, + template_id TEXT NOT NULL, + value TEXT, -- 属性值(以JSON或字符串形式存储) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES attribute_templates(id) ON DELETE CASCADE, + UNIQUE(entity_id, template_id) -- 每个实体每个属性只能有一个值 +); + +-- Phase 5: 属性变更历史表 +CREATE TABLE IF NOT EXISTS attribute_history ( + id TEXT PRIMARY KEY, + entity_id TEXT NOT NULL, + template_id TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, -- 用户ID或"system" + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + change_reason TEXT, -- 变更原因 + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES attribute_templates(id) ON DELETE CASCADE +); + -- 创建索引以提高查询性能 CREATE INDEX IF NOT EXISTS idx_entities_project ON entities(project_id); CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name); @@ -83,3 +171,15 @@ CREATE INDEX IF NOT EXISTS idx_mentions_entity ON entity_mentions(entity_id); CREATE INDEX IF NOT EXISTS idx_mentions_transcript ON entity_mentions(transcript_id); CREATE INDEX IF NOT EXISTS idx_relations_project ON entity_relations(project_id); CREATE INDEX IF NOT EXISTS idx_glossary_project ON glossary(project_id); + +-- Phase 5: 属性相关索引 +CREATE INDEX IF NOT EXISTS idx_attr_templates_project ON attribute_templates(project_id); +CREATE INDEX IF NOT EXISTS idx_entity_attributes_entity ON entity_attributes(entity_id); +CREATE INDEX IF NOT EXISTS idx_entity_attributes_template ON entity_attributes(template_id); +CREATE INDEX IF NOT EXISTS idx_attr_history_entity ON attribute_history(entity_id); + +-- Phase 5: 属性相关索引 +CREATE INDEX IF NOT EXISTS idx_attr_templates_project ON attribute_templates(project_id); +CREATE INDEX IF NOT EXISTS idx_entity_attributes_entity ON entity_attributes(entity_id); +CREATE INDEX IF NOT EXISTS idx_entity_attributes_template ON entity_attributes(template_id); +CREATE INDEX IF NOT EXISTS idx_attr_history_entity ON attribute_history(entity_id); diff --git a/frontend/app.js b/frontend/app.js index a88b16a..7be7949 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -995,6 +995,22 @@ async function loadKnowledgeBase() { switchView('workbench'); setTimeout(() => selectEntity(ent.id), 100); }; + + // 渲染属性预览 + let attrsHtml = ''; + if (ent.attributes && ent.attributes.length > 0) { + attrsHtml = ` +
+ ${ent.attributes.slice(0, 3).map(a => ` + + ${a.name}: ${Array.isArray(a.value) ? a.value.join(', ') : a.value} + + `).join('')} + ${ent.attributes.length > 3 ? `+${ent.attributes.length - 3}` : ''} +
+ `; + } + card.innerHTML = `
${ent.type} @@ -1005,6 +1021,7 @@ async function loadKnowledgeBase() { 📍 ${ent.mention_count || 0} 次提及 · ${ent.appears_in?.length || 0} 个文件
+ ${attrsHtml} `; entityGrid.appendChild(card); }); @@ -1342,36 +1359,453 @@ window.findInferencePath = async function(startEntity, endEntity) { } }; -function renderInferencePaths(data) { - const pathsList = document.getElementById('inferencePathsList'); - - if (!data.paths || data.paths.length === 0) { - pathsList.innerHTML = '

未找到关联路径

'; +// Phase 5: Entity Attributes Management +let currentEntityIdForAttributes = null; +let currentAttributes = []; +let currentTemplates = []; + +// Show entity attributes modal +window.showEntityAttributes = async function(entityId) { + if (entityId) { + currentEntityIdForAttributes = entityId; + } else if (selectedEntity) { + currentEntityIdForAttributes = selectedEntity; + } else { + alert('请先选择一个实体'); return; } - pathsList.innerHTML = data.paths.map((path, idx) => { - const strengthPercent = Math.round(path.strength * 100); - const pathHtml = path.path.map((node, i) => { - if (i === 0) { - return `${node.entity}`; - } - return ` - → ${node.relation} → - ${node.entity} - `; - }).join(''); + const modal = document.getElementById('attributesModal'); + modal.classList.add('show'); + + // Reset form + document.getElementById('attributesAddForm').style.display = 'none'; + document.getElementById('toggleAddAttrBtn').style.display = 'inline-block'; + document.getElementById('saveAttrBtn').style.display = 'none'; + + await loadEntityAttributes(); +}; + +window.hideAttributesModal = function() { + document.getElementById('attributesModal').classList.remove('show'); + currentEntityIdForAttributes = null; +}; + +async function loadEntityAttributes() { + if (!currentEntityIdForAttributes) return; + + try { + const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`); + if (!res.ok) throw new Error('Failed to load attributes'); + + const data = await res.json(); + currentAttributes = data.attributes || []; + + renderAttributesList(); + } catch (err) { + console.error('Load attributes failed:', err); + document.getElementById('attributesList').innerHTML = '

加载失败

'; + } +} + +function renderAttributesList() { + const container = document.getElementById('attributesList'); + + if (currentAttributes.length === 0) { + container.innerHTML = '

暂无属性,点击"添加属性"创建

'; + return; + } + + container.innerHTML = currentAttributes.map(attr => { + let valueDisplay = attr.value; + if (attr.type === 'multiselect' && Array.isArray(attr.value)) { + valueDisplay = attr.value.join(', '); + } return ` -
-
- 路径 ${idx + 1} - 关联强度: ${strengthPercent}% +
+
+
+ ${attr.name} + ${attr.type} +
+
${valueDisplay || '-'}
-
- ${pathHtml} +
+ +
`; }).join(''); } + +window.toggleAddAttributeForm = function() { + const form = document.getElementById('attributesAddForm'); + const toggleBtn = document.getElementById('toggleAddAttrBtn'); + const saveBtn = document.getElementById('saveAttrBtn'); + + if (form.style.display === 'none') { + form.style.display = 'block'; + toggleBtn.style.display = 'none'; + saveBtn.style.display = 'inline-block'; + } else { + form.style.display = 'none'; + toggleBtn.style.display = 'inline-block'; + saveBtn.style.display = 'none'; + } +}; + +window.onAttrTypeChange = function() { + const type = document.getElementById('attrType').value; + const optionsGroup = document.getElementById('attrOptionsGroup'); + const valueContainer = document.getElementById('attrValueContainer'); + + if (type === 'select' || type === 'multiselect') { + optionsGroup.style.display = 'block'; + } else { + optionsGroup.style.display = 'none'; + } + + // Update value input based on type + if (type === 'date') { + valueContainer.innerHTML = ''; + } else if (type === 'number') { + valueContainer.innerHTML = ''; + } else { + valueContainer.innerHTML = ''; + } +}; + +window.saveAttribute = async function() { + if (!currentEntityIdForAttributes) return; + + const name = document.getElementById('attrName').value.trim(); + const type = document.getElementById('attrType').value; + let value = document.getElementById('attrValue').value; + const changeReason = document.getElementById('attrChangeReason').value.trim(); + + if (!name) { + alert('请输入属性名称'); + return; + } + + // Handle options for select/multiselect + let options = null; + if (type === 'select' || type === 'multiselect') { + const optionsStr = document.getElementById('attrOptions').value.trim(); + if (optionsStr) { + options = optionsStr.split(',').map(o => o.trim()).filter(o => o); + } + + // Handle multiselect value + if (type === 'multiselect' && value) { + value = value.split(',').map(v => v.trim()).filter(v => v); + } + } + + // Handle number type + if (type === 'number' && value) { + value = parseFloat(value); + } + + try { + const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + type, + value, + options, + change_reason: changeReason + }) + }); + + if (!res.ok) throw new Error('Failed to save attribute'); + + // Reset form + document.getElementById('attrName').value = ''; + document.getElementById('attrValue').value = ''; + document.getElementById('attrOptions').value = ''; + document.getElementById('attrChangeReason').value = ''; + + // Reload attributes + await loadEntityAttributes(); + + // Hide form + toggleAddAttributeForm(); + + } catch (err) { + console.error('Save attribute failed:', err); + alert('保存失败,请重试'); + } +}; + +window.deleteAttribute = async function(attributeId) { + if (!confirm('确定要删除这个属性吗?')) return; + + try { + const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/${attributeId}`, { + method: 'DELETE' + }); + + if (!res.ok) throw new Error('Failed to delete attribute'); + + await loadEntityAttributes(); + } catch (err) { + console.error('Delete attribute failed:', err); + alert('删除失败'); + } +}; + +// Attribute History +window.showAttributeHistory = async function(attributeName) { + if (!currentEntityIdForAttributes) return; + + const modal = document.getElementById('attrHistoryModal'); + modal.classList.add('show'); + + try { + const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/history?attribute_name=${encodeURIComponent(attributeName)}`); + if (!res.ok) throw new Error('Failed to load history'); + + const data = await res.json(); + renderAttributeHistory(data.history, attributeName); + } catch (err) { + console.error('Load history failed:', err); + document.getElementById('attrHistoryContent').innerHTML = '

加载失败

'; + } +}; + +window.hideAttrHistoryModal = function() { + document.getElementById('attrHistoryModal').classList.remove('show'); +}; + +function renderAttributeHistory(history, attributeName) { + const container = document.getElementById('attrHistoryContent'); + + if (history.length === 0) { + container.innerHTML = `

属性 "${attributeName}" 暂无变更历史

`; + return; + } + + container.innerHTML = history.map(h => { + const date = new Date(h.changed_at).toLocaleString(); + return ` +
+
+ ${h.changed_by || '系统'} + ${date} +
+
+ ${h.old_value || '(无)'} + + ${h.new_value || '(无)'} +
+ ${h.change_reason ? `
原因: ${h.change_reason}
` : ''} +
+ `; + }).join(''); +} + +// Attribute Templates Management +window.showAttributeTemplates = async function() { + const modal = document.getElementById('attrTemplatesModal'); + modal.classList.add('show'); + + document.getElementById('templateForm').style.display = 'none'; + document.getElementById('toggleTemplateBtn').style.display = 'inline-block'; + document.getElementById('saveTemplateBtn').style.display = 'none'; + + await loadAttributeTemplates(); +}; + +window.hideAttrTemplatesModal = function() { + document.getElementById('attrTemplatesModal').classList.remove('show'); +}; + +async function loadAttributeTemplates() { + if (!currentProject) return; + + try { + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`); + if (!res.ok) throw new Error('Failed to load templates'); + + currentTemplates = await res.json(); + renderTemplatesList(); + } catch (err) { + console.error('Load templates failed:', err); + document.getElementById('templatesList').innerHTML = '

加载失败

'; + } +} + +function renderTemplatesList() { + const container = document.getElementById('templatesList'); + + if (currentTemplates.length === 0) { + container.innerHTML = '

暂无模板,点击"新建模板"创建

'; + return; + } + + container.innerHTML = currentTemplates.map(t => { + const optionsStr = t.options ? `选项: ${t.options.join(', ')}` : ''; + return ` +
+
+
+ ${t.name} + ${t.type} + ${t.is_required ? '*' : ''} +
+
${t.description || ''} ${optionsStr}
+
+
+ +
+
+ `; + }).join(''); +} + +window.toggleTemplateForm = function() { + const form = document.getElementById('templateForm'); + const toggleBtn = document.getElementById('toggleTemplateBtn'); + const saveBtn = document.getElementById('saveTemplateBtn'); + + if (form.style.display === 'none') { + form.style.display = 'block'; + toggleBtn.style.display = 'none'; + saveBtn.style.display = 'inline-block'; + } else { + form.style.display = 'none'; + toggleBtn.style.display = 'inline-block'; + saveBtn.style.display = 'none'; + } +}; + +window.onTemplateTypeChange = function() { + const type = document.getElementById('templateType').value; + const optionsGroup = document.getElementById('templateOptionsGroup'); + + if (type === 'select' || type === 'multiselect') { + optionsGroup.style.display = 'block'; + } else { + optionsGroup.style.display = 'none'; + } +}; + +window.saveTemplate = async function() { + if (!currentProject) return; + + const name = document.getElementById('templateName').value.trim(); + const type = document.getElementById('templateType').value; + const description = document.getElementById('templateDesc').value.trim(); + const isRequired = document.getElementById('templateRequired').checked; + const defaultValue = document.getElementById('templateDefault').value.trim(); + + if (!name) { + alert('请输入模板名称'); + return; + } + + let options = null; + if (type === 'select' || type === 'multiselect') { + const optionsStr = document.getElementById('templateOptions').value.trim(); + if (optionsStr) { + options = optionsStr.split(',').map(o => o.trim()).filter(o => o); + } + } + + try { + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + type, + description, + options, + is_required: isRequired, + default_value: defaultValue || null + }) + }); + + if (!res.ok) throw new Error('Failed to save template'); + + // Reset form + document.getElementById('templateName').value = ''; + document.getElementById('templateDesc').value = ''; + document.getElementById('templateOptions').value = ''; + document.getElementById('templateDefault').value = ''; + document.getElementById('templateRequired').checked = false; + + await loadAttributeTemplates(); + toggleTemplateForm(); + + } catch (err) { + console.error('Save template failed:', err); + alert('保存失败'); + } +}; + +window.deleteTemplate = async function(templateId) { + if (!confirm('确定要删除这个模板吗?')) return; + + try { + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates/${templateId}`, { + method: 'DELETE' + }); + + if (!res.ok) throw new Error('Failed to delete template'); + + await loadAttributeTemplates(); + } catch (err) { + console.error('Delete template failed:', err); + alert('删除失败'); + } +}; + +// Search entities by attributes +window.searchByAttributes = async function() { + if (!currentProject) return; + + const filterName = document.getElementById('attrFilterName').value; + const filterValue = document.getElementById('attrFilterValue').value; + const filterOp = document.getElementById('attrFilterOp').value; + + if (!filterName || !filterValue) { + alert('请输入筛选条件'); + return; + } + + try { + const filters = JSON.stringify([{ name: filterName, value: filterValue, operator: filterOp }]); + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/entities/search-by-attributes?filters=${encodeURIComponent(filters)}`); + + if (!res.ok) throw new Error('Search failed'); + + const entities = await res.json(); + + // Update entity grid + const grid = document.getElementById('kbEntityGrid'); + if (entities.length === 0) { + grid.innerHTML = '

未找到匹配的实体

'; + return; + } + + grid.innerHTML = entities.map(ent => ` +
+
+ ${ent.type} + ${ent.name} +
+
${ent.definition || '暂无定义'}
+
+ `).join(''); + + } catch (err) { + console.error('Search by attributes failed:', err); + alert('搜索失败'); + } +}; diff --git a/frontend/workbench.html b/frontend/workbench.html index c93396f..04a25d9 100644 --- a/frontend/workbench.html +++ b/frontend/workbench.html @@ -1270,6 +1270,142 @@ 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-10px); } } + /* Phase 5: Entity Attributes */ + .attributes-list { + max-height: 300px; + overflow-y: auto; + } + .attribute-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #0a0a0a; + border-radius: 6px; + margin-bottom: 8px; + } + .attribute-info { + flex: 1; + } + .attribute-name { + font-weight: 500; + color: #e0e0e0; + margin-bottom: 4px; + } + .attribute-value { + color: #00d4ff; + font-size: 0.9rem; + } + .attribute-type { + font-size: 0.7rem; + color: #666; + margin-left: 8px; + } + .attribute-actions { + display: flex; + gap: 8px; + } + .attribute-btn { + background: transparent; + border: 1px solid #333; + color: #888; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; + } + .attribute-btn:hover { + border-color: #00d4ff; + color: #00d4ff; + } + .attribute-btn.delete:hover { + border-color: #ff6b6b; + color: #ff6b6b; + } + .attributes-add-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #333; + } + .templates-list { + max-height: 300px; + overflow-y: auto; + } + .template-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #0a0a0a; + border-radius: 6px; + margin-bottom: 8px; + } + .template-info { + flex: 1; + } + .template-name { + font-weight: 500; + color: #e0e0e0; + } + .template-desc { + font-size: 0.8rem; + color: #666; + margin-top: 2px; + } + .history-item { + padding: 12px; + background: #0a0a0a; + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.85rem; + } + .history-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + color: #888; + } + .history-change { + display: flex; + align-items: center; + gap: 8px; + } + .history-old { + color: #ff6b6b; + text-decoration: line-through; + } + .history-new { + color: #4ecdc4; + } + .history-arrow { + color: #666; + } + + /* Phase 5: Attribute filter in KB */ + .attribute-filter-bar { + display: flex; + gap: 12px; + margin-bottom: 16px; + padding: 12px; + background: #141414; + border-radius: 8px; + flex-wrap: wrap; + } + .attribute-filter-item { + display: flex; + align-items: center; + gap: 8px; + } + .attribute-filter-item select, + .attribute-filter-item input { + background: #1a1a1a; + border: 1px solid #333; + color: #e0e0e0; + padding: 6px 10px; + border-radius: 4px; + font-size: 0.85rem; + } + @@ -1441,7 +1577,32 @@
-

所有实体

+
+

所有实体

+ +
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
@@ -1581,6 +1742,114 @@
+ + + + + + + + +