Files
2026-05-12 09:41:56 +08:00

1347 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
基于Kimi API的智能菜谱解析AI Agent
"""
import os
import json
import re
import time
from openai import OpenAI
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, asdict
import pandas as pd
import csv
from datetime import datetime
@dataclass
class IngredientInfo:
"""食材信息"""
name: str
amount: str = ""
unit: str = ""
category: str = ""
is_main: bool = True # 是否主要食材
@dataclass
class CookingStep:
"""烹饪步骤"""
step_number: int
description: str
methods: List[str] # 使用的烹饪方法
tools: List[str] # 需要的工具
time_estimate: str = "" # 时间估计
@dataclass
class RecipeInfo:
"""菜谱信息"""
name: str
difficulty: int # 1-5星
category: str
cuisine_type: str = "" # 菜系
prep_time: str = ""
cook_time: str = ""
servings: str = ""
ingredients: List[IngredientInfo] = None
steps: List[CookingStep] = None
tags: List[str] = None
nutrition_info: Dict = None
def __post_init__(self):
if self.ingredients is None:
self.ingredients = []
if self.steps is None:
self.steps = []
if self.tags is None:
self.tags = []
if self.nutrition_info is None:
self.nutrition_info = {}
class KimiRecipeAgent:
"""Kimi菜谱解析AI Agent"""
def __init__(self, api_key: str, base_url: str = "https://api.moonshot.cn/v1"):
self.api_key = api_key
self.base_url = base_url
self.client = OpenAI(
api_key=api_key,
base_url=base_url
)
# 目录名到分类的映射
self.directory_category_mapping = {
"vegetable_dish": "素菜",
"meat_dish": "荤菜",
"aquatic": "水产",
"breakfast": "早餐",
"staple": "主食",
"soup": "汤类",
"dessert": "甜品",
"drink": "饮料",
"condiment": "调料",
"semi-finished": "半成品"
}
# 排除的目录
self.excluded_directories = ["template", ".github", "tips", "starsystem"]
# 预定义的食材分类
self.ingredient_categories = {
"蔬菜": ["茄子", "辣椒", "洋葱", "大葱", "西红柿", "土豆", "萝卜", "白菜", "豆腐"],
"调料": ["盐", "酱油", "醋", "糖", "料酒", "生抽", "老抽", "蚝油", "味精"],
"蛋白质": ["鸡蛋", "肉", "鱼", "虾", "鸡", "猪", "牛", "羊"],
"淀粉类": ["面粉", "淀粉", "米", "面条", "面包", "土豆"]
}
# 预定义的烹饪方法
self.cooking_methods = ["炒", "炸", "煮", "蒸", "烤", "炖", "焖", "煎", "红烧", "清炒", "爆炒"]
# 预定义的工具
self.cooking_tools = ["炒锅", "平底锅", "蒸锅", "刀", "案板", "筷子", "锅铲", "勺子"]
def call_kimi_api(self, messages: List[Dict], max_retries: int = 3) -> str:
"""调用Kimi API"""
for attempt in range(max_retries):
try:
response = self.client.chat.completions.create(
model="kimi-k2-0711-preview",
messages=messages,
temperature=0.3,
max_tokens=2048,
stream=False
)
return response.choices[0].message.content
except Exception as e:
print(f"API调用错误 (尝试 {attempt + 1}): {str(e)}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # 指数退避
raise Exception("Kimi API调用失败")
def infer_category_from_path(self, file_path: str) -> str:
"""根据文件路径推断菜谱分类"""
path_parts = file_path.replace('\\', '/').split('/')
for part in path_parts:
if part in self.directory_category_mapping:
return self.directory_category_mapping[part]
return "" # 如果无法推断,返回空字符串
def extract_recipe_info(self, markdown_content: str, file_path: str = "") -> RecipeInfo:
"""使用AI提取菜谱信息"""
# 根据路径推断分类
inferred_category = self.infer_category_from_path(file_path)
# 构建提示词
category_hint = f",根据文件路径推断此菜谱属于【{inferred_category}】分类" if inferred_category else ""
prompt = f"""
请分析以下标准化格式的菜谱Markdown文档,提取结构化信息并以JSON格式返回。
文件路径: {file_path}
菜谱内容:
{markdown_content}
## 文档结构说明
此菜谱遵循标准格式,包含以下固定二级标题:
- ## 必备原料和工具:列出所有食材和工具
- ## 计算:包含份量计算和具体用量
- ## 操作:详细的烹饪步骤
- ## 附加内容:补充说明和技巧提示(需要过滤无关内容)
## 提取规则
1. **菜谱名称**:从一级标题(# XXX的做法)提取
2. **难度等级**:从"预估烹饪难度:★★★"中统计★的数量
3. **菜谱分类**:可以是多个分类,用逗号分隔(如"早餐,素菜"表示既是早餐又是素菜)
4. **食材信息**:从"必备原料和工具"和"计算"部分提取,合并用量信息
5. **烹饪步骤**:从"操作"部分的有序列表提取
6. **技巧补充**:从"附加内容"提取有用的烹饪技巧,忽略模板文字(如"如果您遵循本指南...Issue或Pull request"等)
请返回标准JSON格式{category_hint}
{{
"name": "菜谱名称(去掉'的做法'后缀)",
"difficulty": 1-5的数字(根据★数量:★=1, ★★=2, ★★★=3, ★★★★=4, ★★★★★=5),
"category": "{inferred_category if inferred_category else '菜谱分类(素菜/荤菜/水产/早餐/主食/汤类/甜品/饮料/调料,支持多个分类用逗号分隔,如\"早餐,素菜\"'}",
"cuisine_type": "菜系(川菜/粤菜/鲁菜/苏菜/闽菜/浙菜/湘菜/徽菜/东北菜/西北菜/等,如果不明确则为空)",
"prep_time": "准备时间(从腌制、切菜等步骤推断)",
"cook_time": "烹饪时间(从炒制、炖煮等步骤推断)",
"servings": "份数/人数(从'计算'部分提取,如'2个人食用'",
"ingredients": [
{{
"name": "食材名称",
"amount": "用量数字(从计算部分提取具体数值)",
"unit": "单位(克、个、毫升、片等)",
"category": "食材类别(蔬菜/调料/蛋白质/淀粉类/其他)",
"is_main": true/false(主要食材为true,调料为false)
}}
],
"steps": [
{{
"step_number": 1,
"description": "步骤详细描述",
"methods": ["使用的烹饪方法:炒、炸、煮、蒸、烤、炖、焖、煎、红烧、腌制、切等"],
"tools": ["需要的工具:炒锅、平底锅、蒸锅、刀、案板、筷子、锅铲、盆等"],
"time_estimate": "时间估计(如步骤中提到'15秒'、'30秒'、'10-15分钟'等)"
}}
],
"tags": ["从附加内容中提取的有用技巧标签"],
"nutrition_info": {{
"calories": "",
"protein": "",
"carbs": "",
"fat": ""
}}
}}
## 重要提示:
1. 从"计算"部分精确提取食材用量和单位
2. 从"操作"部分的有序列表逐步解析烹饪步骤
3. 从"附加内容"中只提取烹饪技巧,忽略"Issue或Pull request"等模板文字
4. 食材分类要准确:蔬菜(包括各种菜类)、调料(盐、酱油、糖等)、蛋白质(鱼、肉、蛋)、淀粉类(面粉、米等)
5. 菜谱分类支持多重分类:如早餐类的蔬菜粥可以分类为"早餐,素菜,主食"(逗号分隔)
6. 当遇到"适量"、"少许"等非具体数值时,不要忘记加引号,如"amount": "适量"
7. 只返回标准JSON格式,确保语法正确
"""
messages = [
{"role": "system", "content": "你是一个专业的菜谱分析专家,擅长从中文菜谱中提取结构化信息。"},
{"role": "user", "content": prompt}
]
try:
response = self.call_kimi_api(messages)
# 清理响应,确保是有效的JSON
response = response.strip()
if response.startswith("```json"):
response = response[7:]
if response.endswith("```"):
response = response[:-3]
response = response.strip()
# 解析JSON
recipe_data = json.loads(response)
# 转换为RecipeInfo对象
recipe_info = RecipeInfo(
name=recipe_data.get("name", ""),
difficulty=recipe_data.get("difficulty", 3),
category=recipe_data.get("category", ""),
cuisine_type=recipe_data.get("cuisine_type", ""),
prep_time=recipe_data.get("prep_time", ""),
cook_time=recipe_data.get("cook_time", ""),
servings=recipe_data.get("servings", ""),
nutrition_info=recipe_data.get("nutrition_info", {})
)
# 转换食材信息
for ing_data in recipe_data.get("ingredients", []):
ingredient = IngredientInfo(
name=ing_data.get("name", ""),
amount=ing_data.get("amount", ""),
unit=ing_data.get("unit", ""),
category=ing_data.get("category", ""),
is_main=ing_data.get("is_main", True)
)
recipe_info.ingredients.append(ingredient)
# 转换步骤信息
for step_data in recipe_data.get("steps", []):
step = CookingStep(
step_number=step_data.get("step_number", 0),
description=step_data.get("description", ""),
methods=step_data.get("methods", []),
tools=step_data.get("tools", []),
time_estimate=step_data.get("time_estimate", "")
)
recipe_info.steps.append(step)
# 添加标签
recipe_info.tags = recipe_data.get("tags", [])
return recipe_info
except json.JSONDecodeError as e:
print(f"JSON解析错误: {e}")
print(f"原始响应: {response}")
return self._fallback_parse(markdown_content)
except Exception as e:
print(f"AI解析错误: {e}")
return self._fallback_parse(markdown_content)
def _fallback_parse(self, content: str) -> RecipeInfo:
"""备用解析方法(基于规则)"""
lines = content.strip().split('\n')
# 提取菜谱名称
name = ""
for line in lines:
if line.startswith('# '):
name = line[2:].replace('的做法', '').strip()
break
# 提取难度
difficulty = 3 # 默认3星
for line in lines:
if '★' in line:
stars = line.count('★')
difficulty = min(max(stars, 1), 5)
break
# 简单分类判断
category = "其他"
if any(keyword in name for keyword in ["蛋", "豆腐"]):
category = "素菜"
elif any(keyword in name for keyword in ["肉", "鸡", "鱼", "虾"]):
category = "荤菜"
return RecipeInfo(
name=name or "未知菜谱",
difficulty=difficulty,
category=category
)
class RecipeKnowledgeGraphBuilder:
"""菜谱知识图谱构建器 - 支持分批保存和断点续传"""
def __init__(self, ai_agent: KimiRecipeAgent, output_dir: str = "./ai_output", batch_size: int = 20):
self.ai_agent = ai_agent
self.concepts = []
self.relationships = []
self.concept_id_counter = 201000000
self.output_dir = output_dir
self.batch_size = batch_size
self.progress_file = os.path.join(output_dir, "progress.json")
self.processed_files = set()
self.current_batch = 0
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
# 初始化预定义概念和关系类型映射
self._init_predefined_concepts()
self._init_relationship_mappings()
def _init_relationship_mappings(self):
"""初始化关系类型映射"""
self.relationship_type_mapping = {
"has_ingredient": "801000001",
"requires_tool": "801000002",
"has_step": "801000003",
"belongs_to_category": "801000004",
"has_difficulty": "801000005",
"uses_method": "801000006",
"has_amount": "801000007",
"step_follows": "801000008",
"serves_people": "801000009",
"cooking_time": "801000010",
"prep_time": "801000011"
}
def _init_predefined_concepts(self):
"""初始化预定义概念"""
self.predefined_concepts = [
# 根概念
{
"concept_id": "100000000",
"concept_type": "Root",
"name": "烹饪概念",
"fsn": "烹饪概念 (Culinary Concept)",
"preferred_term": "烹饪概念"
},
# 顶级概念
{
"concept_id": "200000000",
"concept_type": "Recipe",
"name": "菜谱",
"fsn": "菜谱 (Recipe)",
"preferred_term": "菜谱"
},
{
"concept_id": "300000000",
"concept_type": "Ingredient",
"name": "食材",
"fsn": "食材 (Ingredient)",
"preferred_term": "食材"
},
{
"concept_id": "400000000",
"concept_type": "CookingMethod",
"name": "烹饪方法",
"fsn": "烹饪方法 (Cooking Method)",
"preferred_term": "烹饪方法"
},
{
"concept_id": "500000000",
"concept_type": "CookingTool",
"name": "烹饪工具",
"fsn": "烹饪工具 (Cooking Tool)",
"preferred_term": "烹饪工具"
},
# 难度等级
{
"concept_id": "610000000",
"concept_type": "DifficultyLevel",
"name": "一星",
"fsn": "一星 (One Star)",
"preferred_term": "一星"
},
{
"concept_id": "620000000",
"concept_type": "DifficultyLevel",
"name": "二星",
"fsn": "二星 (Two Star)",
"preferred_term": "二星"
},
{
"concept_id": "630000000",
"concept_type": "DifficultyLevel",
"name": "三星",
"fsn": "三星 (Three Star)",
"preferred_term": "三星"
},
{
"concept_id": "640000000",
"concept_type": "DifficultyLevel",
"name": "四星",
"fsn": "四星 (Four Star)",
"preferred_term": "四星"
},
{
"concept_id": "650000000",
"concept_type": "DifficultyLevel",
"name": "五星",
"fsn": "五星 (Five Star)",
"preferred_term": "五星"
},
# 菜谱分类
{
"concept_id": "710000000",
"concept_type": "RecipeCategory",
"name": "素菜",
"fsn": "素菜 (Vegetarian Dish)",
"preferred_term": "素菜"
},
{
"concept_id": "720000000",
"concept_type": "RecipeCategory",
"name": "荤菜",
"fsn": "荤菜 (Meat Dish)",
"preferred_term": "荤菜"
},
{
"concept_id": "730000000",
"concept_type": "RecipeCategory",
"name": "水产",
"fsn": "水产 (Aquatic Product)",
"preferred_term": "水产"
},
{
"concept_id": "740000000",
"concept_type": "RecipeCategory",
"name": "早餐",
"fsn": "早餐 (Breakfast)",
"preferred_term": "早餐"
},
{
"concept_id": "750000000",
"concept_type": "RecipeCategory",
"name": "主食",
"fsn": "主食 (Staple Food)",
"preferred_term": "主食"
},
{
"concept_id": "760000000",
"concept_type": "RecipeCategory",
"name": "汤类",
"fsn": "汤类 (Soup)",
"preferred_term": "汤类"
},
{
"concept_id": "770000000",
"concept_type": "RecipeCategory",
"name": "甜品",
"fsn": "甜品 (Dessert)",
"preferred_term": "甜品"
},
{
"concept_id": "780000000",
"concept_type": "RecipeCategory",
"name": "饮料",
"fsn": "饮料 (Beverage)",
"preferred_term": "饮料"
},
{
"concept_id": "790000000",
"concept_type": "RecipeCategory",
"name": "调料",
"fsn": "调料 (Condiment)",
"preferred_term": "调料"
}
]
def save_progress(self, current_file: str = None, total_files: int = 0, processed_count: int = 0):
"""保存处理进度"""
progress_data = {
"processed_files": list(self.processed_files),
"current_file": current_file,
"total_files": total_files,
"processed_count": processed_count,
"current_batch": self.current_batch,
"concept_id_counter": self.concept_id_counter,
"timestamp": datetime.now().isoformat(),
"concepts_count": len(self.concepts),
"relationships_count": len(self.relationships)
}
with open(self.progress_file, 'w', encoding='utf-8') as f:
json.dump(progress_data, f, ensure_ascii=False, indent=2)
def load_progress(self) -> Dict:
"""加载处理进度"""
if os.path.exists(self.progress_file):
try:
with open(self.progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
self.processed_files = set(progress_data.get("processed_files", []))
self.current_batch = progress_data.get("current_batch", 0)
self.concept_id_counter = progress_data.get("concept_id_counter", 201000000)
return progress_data
except Exception as e:
print(f"警告: 加载进度文件失败 - {str(e)}")
return {}
return {}
def save_batch_data(self, batch_num: int = None):
"""保存当前批次数据"""
if batch_num is None:
batch_num = self.current_batch
batch_output_dir = os.path.join(self.output_dir, f"batch_{batch_num:03d}")
os.makedirs(batch_output_dir, exist_ok=True)
# 保存概念数据
if self.concepts:
concepts_df = pd.DataFrame(self.concepts)
concepts_file = os.path.join(batch_output_dir, "concepts.csv")
concepts_df.to_csv(concepts_file, index=False, encoding='utf-8')
# 保存关系数据
if self.relationships:
relationships_df = pd.DataFrame(self.relationships)
relationships_file = os.path.join(batch_output_dir, "relationships.csv")
relationships_df.to_csv(relationships_file, index=False, encoding='utf-8')
print(f"批次 {batch_num} 已保存")
return batch_output_dir
def merge_all_batches(self):
"""合并所有批次数据到最终输出文件"""
print("合并批次数据...")
all_concepts = []
all_relationships = []
# 收集所有批次数据
batch_dirs = [d for d in os.listdir(self.output_dir)
if d.startswith("batch_") and os.path.isdir(os.path.join(self.output_dir, d))]
batch_dirs.sort()
for batch_dir in batch_dirs:
batch_path = os.path.join(self.output_dir, batch_dir)
# 读取概念文件
concepts_file = os.path.join(batch_path, "concepts.csv")
if os.path.exists(concepts_file):
batch_concepts = pd.read_csv(concepts_file)
all_concepts.append(batch_concepts)
# 读取关系文件
relationships_file = os.path.join(batch_path, "relationships.csv")
if os.path.exists(relationships_file):
batch_relationships = pd.read_csv(relationships_file)
all_relationships.append(batch_relationships)
# 合并数据
if all_concepts:
final_concepts = pd.concat(all_concepts, ignore_index=True)
final_concepts.to_csv(os.path.join(self.output_dir, "concepts.csv"),
index=False, encoding='utf-8')
print(f"合并概念: {len(final_concepts)} 个")
if all_relationships:
final_relationships = pd.concat(all_relationships, ignore_index=True)
final_relationships.to_csv(os.path.join(self.output_dir, "relationships.csv"),
index=False, encoding='utf-8')
print(f"合并关系: {len(final_relationships)} 个")
return len(final_concepts) if all_concepts else 0, len(final_relationships) if all_relationships else 0
def generate_concept_id(self) -> str:
"""生成新的概念ID"""
self.concept_id_counter += 1
return str(self.concept_id_counter)
def process_recipe(self, markdown_content: str, file_path: str) -> Dict:
"""处理单个菜谱"""
# 处理菜谱
# 使用AI提取菜谱信息
recipe_info = self.ai_agent.extract_recipe_info(markdown_content, file_path)
# 生成概念ID
recipe_id = self.generate_concept_id()
# 创建菜谱概念
recipe_concept = {
"concept_id": recipe_id,
"concept_type": "Recipe",
"name": recipe_info.name,
"fsn": f"{recipe_info.name} (Recipe)",
"preferred_term": recipe_info.name,
"synonyms": self._generate_recipe_synonyms(recipe_info.name, recipe_info.category),
"category": recipe_info.category,
"difficulty": recipe_info.difficulty,
"cuisine_type": recipe_info.cuisine_type,
"prep_time": recipe_info.prep_time,
"cook_time": recipe_info.cook_time,
"servings": recipe_info.servings,
"tags": ",".join(recipe_info.tags),
"file_path": file_path
}
self.concepts.append(recipe_concept)
# 处理食材
for ingredient in recipe_info.ingredients:
ing_id = self.generate_concept_id()
ing_concept = {
"concept_id": ing_id,
"concept_type": "Ingredient",
"name": ingredient.name,
"fsn": f"{ingredient.name} (Ingredient)",
"preferred_term": ingredient.name,
"synonyms": self._generate_ingredient_synonyms(ingredient.name),
"category": ingredient.category,
"amount": ingredient.amount,
"unit": ingredient.unit,
"is_main": ingredient.is_main
}
self.concepts.append(ing_concept)
# 添加关系:菜谱包含食材
self.relationships.append({
"relationship_id": f"R_{len(self.relationships) + 1:06d}",
"source_id": recipe_id,
"target_id": ing_id,
"relationship_type": self.relationship_type_mapping["has_ingredient"],
"amount": ingredient.amount,
"unit": ingredient.unit
})
# 处理步骤
for step in recipe_info.steps:
step_id = self.generate_concept_id()
step_concept = {
"concept_id": step_id,
"concept_type": "CookingStep",
"name": f"步骤{step.step_number}",
"fsn": f"步骤{step.step_number} (Cooking Step)",
"preferred_term": f"步骤{step.step_number}",
"description": step.description,
"step_number": step.step_number,
"methods": ",".join(step.methods),
"tools": ",".join(step.tools),
"time_estimate": step.time_estimate
}
self.concepts.append(step_concept)
# 添加关系:菜谱包含步骤
self.relationships.append({
"relationship_id": f"R_{len(self.relationships) + 1:06d}",
"source_id": recipe_id,
"target_id": step_id,
"relationship_type": self.relationship_type_mapping["has_step"],
"step_order": step.step_number
})
# 添加分类关系 - 支持多重分类
category_mapping = {
"素菜": "710000000",
"荤菜": "720000000",
"水产": "730000000",
"早餐": "740000000",
"主食": "750000000",
"汤类": "760000000",
"甜品": "770000000",
"饮料": "780000000",
"调料": "790000000"
}
# 处理多重分类(支持逗号分隔的多个分类)
categories = [cat.strip() for cat in recipe_info.category.split(',') if cat.strip()]
for category in categories:
if category in category_mapping:
self.relationships.append({
"relationship_id": f"R_{len(self.relationships) + 1:06d}",
"source_id": recipe_id,
"target_id": category_mapping[category],
"relationship_type": self.relationship_type_mapping["belongs_to_category"]
})
# 添加难度关系
difficulty_mapping = {
1: "610000000", # 一星
2: "620000000", # 二星
3: "630000000", # 三星
4: "640000000", # 四星
5: "650000000" # 五星
}
if recipe_info.difficulty in difficulty_mapping:
self.relationships.append({
"relationship_id": f"R_{len(self.relationships) + 1:06d}",
"source_id": recipe_id,
"target_id": difficulty_mapping[recipe_info.difficulty],
"relationship_type": self.relationship_type_mapping["has_difficulty"]
})
return recipe_concept
def _generate_recipe_synonyms(self, name: str, category: str) -> List[str]:
"""生成菜谱的同义词列表"""
synonyms = []
# 基于菜谱名称生成变体
if name.endswith("的做法"):
base_name = name.replace("的做法", "")
synonyms.extend([
f"{base_name}制作方法",
f"{base_name}烹饪方法",
base_name
])
# 基于烹饪方法生成别名(注意:只有真正的同义词才映射)
cooking_method_mappings = {
"红烧": ["braised"], # 红烧 = 英文braised
"糖醋": ["sweet and sour"], # 糖醋 = 英文sweet and sour
"清炒": ["炒制", "stir-fried"], # 清炒 = 炒制 = 英文stir-fried
"蒸": ["清蒸", "steamed"], # 蒸 = 清蒸 = 英文steamed
"炖": ["煲", "stewed"], # 炖 = 煲 = 英文stewed
"烤": ["烘烤", "roasted", "baked"], # 烤 = 烘烤 = 英文roasted/baked
"炸": ["油炸", "deep-fried"], # 炸 = 油炸 = 英文deep-fried
"焖": ["闷", "braised"], # 焖 = 闷 = 某种形式的braised
"煎": ["pan-fried"], # 煎 = 英文pan-fried
"爆炒": ["stir-fried"], # 爆炒 = stir-fried的一种
"白切": ["boiled"], # 白切 = 水煮的一种
"油焖": ["oil-braised"] # 油焖 = oil-braised
}
for method, variants in cooking_method_mappings.items():
if method in name:
for variant in variants:
if variant != method: # 避免重复
synonym = name.replace(method, variant)
if synonym != name:
synonyms.append(synonym)
# 基于食材生成别名(提取主要食材)
ingredient_aliases = {
"茄子": ["青茄子", "紫茄子", "eggplant"],
"土豆": ["马铃薯", "洋芋", "potato"],
"西红柿": ["番茄", "tomato"],
"青椒": ["彩椒", "甜椒", "bell pepper"],
"豆腐": ["嫩豆腐", "老豆腐", "tofu"],
"白菜": ["大白菜", "小白菜", "cabbage"],
"萝卜": ["白萝卜", "胡萝卜", "radish"]
}
for ingredient, aliases in ingredient_aliases.items():
if ingredient in name:
for alias in aliases:
if alias != ingredient:
synonym = name.replace(ingredient, alias)
if synonym != name:
synonyms.append(synonym)
# 基于地域特色添加别名
regional_mappings = {
"川味": ["四川风味", "川菜风格"],
"粤式": ["广东风味", "粤菜风格"],
"京味": ["北京风味", "京菜风格"],
"湘味": ["湖南风味", "湘菜风格"]
}
for region, variants in regional_mappings.items():
if region in name:
for variant in variants:
synonym = name.replace(region, variant)
if synonym != name:
synonyms.append(synonym)
# 去重并返回,按语言分类
unique_synonyms = list(set(synonyms))
return self._categorize_synonyms_by_language(unique_synonyms)
def _categorize_synonyms_by_language(self, synonyms: List[str]) -> List[dict]:
"""按语言分类同义词"""
categorized = []
for synonym in synonyms:
# 检测语言
if self._is_english(synonym):
categorized.append({
"term": synonym,
"language": "en",
"language_code": "en-US"
})
elif self._is_chinese(synonym):
categorized.append({
"term": synonym,
"language": "zh",
"language_code": "zh-CN"
})
else:
# 默认为中文
categorized.append({
"term": synonym,
"language": "zh",
"language_code": "zh-CN"
})
return categorized
def _is_english(self, text: str) -> bool:
"""检测是否为英文"""
import re
# 检查是否主要包含英文字母和空格
english_chars = re.findall(r'[a-zA-Z\s\-]', text)
return len(english_chars) / len(text) > 0.7 if text else False
def _is_chinese(self, text: str) -> bool:
"""检测是否为中文"""
import re
# 检查是否包含中文字符
chinese_chars = re.findall(r'[\u4e00-\u9fff]', text)
return len(chinese_chars) > 0
def _format_synonyms_for_neo4j(self, synonyms) -> str:
"""格式化同义词用于Neo4j导出"""
import pandas as pd
import json
# 处理NaN值和空值
if pd.isna(synonyms) or not synonyms:
return ""
# 如果是字符串,尝试解析为JSON
if isinstance(synonyms, str):
if synonyms.strip() == "[]" or synonyms.strip() == "":
return ""
try:
synonyms = json.loads(synonyms)
except (json.JSONDecodeError, ValueError):
# 如果不是JSON,当作单个同义词处理
return synonyms.strip()
# 如果不是列表,返回空字符串
if not isinstance(synonyms, (list, tuple)):
return ""
formatted_terms = []
for synonym_data in synonyms:
if isinstance(synonym_data, dict):
# 新格式:包含语言信息
term = synonym_data.get('term', '')
lang = synonym_data.get('language', 'zh')
if term:
formatted_terms.append(f"{term}({lang})")
else:
# 兼容旧格式:纯字符串
if synonym_data and str(synonym_data).strip():
formatted_terms.append(str(synonym_data).strip())
return "|".join(formatted_terms)
def _generate_ingredient_synonyms(self, name: str) -> List[str]:
"""生成食材的同义词列表"""
ingredient_synonym_dict = {
# 蔬菜类
"青茄子": ["茄子", "紫茄子", "圆茄"],
"西红柿": ["番茄", "洋柿子"],
"土豆": ["马铃薯", "洋芋", "地蛋"],
"红薯": ["地瓜", "甘薯", "山芋"],
"玉米": ["苞米", "玉蜀黍"],
"青椒": ["柿子椒", "甜椒", "彩椒"],
"大葱": ["葱白", "韭葱"],
"小葱": ["香葱", "细葱"],
"香菜": ["芫荽", "胡荽"],
"菠菜": ["赤根菜", "波斯菜"],
# 调料类
"生抽": ["淡色酱油", "鲜味酱油"],
"老抽": ["深色酱油", "红烧酱油"],
"料酒": ["黄酒", "绍兴酒"],
"白糖": ["细砂糖", "绵白糖"],
"冰糖": ["冰片糖", "块糖"],
"八角": ["大料", "茴香"],
# 蛋白质类
"鸡蛋": ["鸡子", "土鸡蛋"],
"豆腐": ["水豆腐", "嫩豆腐"]
}
synonyms = ingredient_synonym_dict.get(name, [])
return self._categorize_synonyms_by_language(synonyms)
def batch_process_recipes(self, recipe_dir: str, resume: bool = True) -> Tuple[int, int]:
"""批量处理菜谱目录 - 支持断点续传和分批保存"""
import glob
# 检查是否要恢复之前的进度
progress_data = {}
if resume:
progress_data = self.load_progress()
if progress_data:
processed_count = progress_data.get("processed_count", 0)
print(f"检测到未完成任务,已处理: {processed_count} 个菜谱")
confirm = input("\n是否继续之前的处理? (Y/n): ").strip().lower()
if confirm == 'n':
print("重新开始处理...")
self.processed_files.clear()
self.current_batch = 0
self.concept_id_counter = 201000000
# 清理进度文件
if os.path.exists(self.progress_file):
os.remove(self.progress_file)
else:
print("继续之前的处理...")
# 专门扫描dishes目录
dishes_dir = os.path.join(recipe_dir, "dishes")
if not os.path.exists(dishes_dir):
# 如果没有dishes目录,则扫描整个目录(向后兼容)
dishes_dir = recipe_dir
print(f"未找到dishes目录,扫描整个目录: {recipe_dir}")
else:
print(f"扫描菜谱目录: {dishes_dir}")
recipe_files = glob.glob(os.path.join(dishes_dir, "**/*.md"), recursive=True)
# 过滤排除的目录
filtered_files = []
for recipe_file in recipe_files:
relative_path = os.path.relpath(recipe_file, recipe_dir)
path_parts = relative_path.replace('\\', '/').split('/')
# 检查是否包含排除的目录
if any(excluded in path_parts for excluded in self.ai_agent.excluded_directories):
continue
filtered_files.append(recipe_file)
recipe_files = filtered_files
# 过滤掉已处理的文件
remaining_files = []
for recipe_file in recipe_files:
relative_path = os.path.relpath(recipe_file, recipe_dir)
if relative_path not in self.processed_files:
remaining_files.append(recipe_file)
total_files = len(recipe_files)
remaining_count = len(remaining_files)
already_processed = len(self.processed_files)
print(f"📊 文件统计:")
print(f" - 总文件数: {total_files}")
print(f" - 已处理: {already_processed}")
print(f" - 待处理: {remaining_count}")
if remaining_count == 0:
print("✅ 所有文件都已处理完成!")
return already_processed, 0
processed_count = already_processed
failed_count = 0
current_batch_count = 0
try:
for i, recipe_file in enumerate(remaining_files):
try:
with open(recipe_file, 'r', encoding='utf-8') as f:
content = f.read()
relative_path = os.path.relpath(recipe_file, recipe_dir)
# 处理菜谱
self.process_recipe(content, relative_path)
# 标记为已处理
self.processed_files.add(relative_path)
processed_count += 1
current_batch_count += 1
# 简化进度显示
if processed_count % 20 == 0:
progress = (processed_count / total_files) * 100
print(f"进度: {processed_count}/{total_files} ({progress:.1f}%)")
# 保存进度(每处理5个文件保存一次进度)
if processed_count % 5 == 0:
self.save_progress(relative_path, total_files, processed_count)
# 检查是否需要保存批次
if current_batch_count >= self.batch_size:
self.save_batch_data()
# 重置当前批次
self.concepts.clear()
self.relationships.clear()
self.current_batch += 1
current_batch_count = 0
except Exception as e:
failed_count += 1
print(f"❌ 处理失败: {recipe_file} - {str(e)}")
continue
# 保存最后一个批次(如果有数据)
if current_batch_count > 0:
self.save_batch_data()
# 最终保存进度
self.save_progress("COMPLETED", total_files, processed_count)
print(f"处理完成: 成功 {processed_count - already_processed} 个,失败 {failed_count} 个")
except KeyboardInterrupt:
print(f"用户中断,已保存进度: {processed_count}/{total_files}")
# 保存当前批次数据
if current_batch_count > 0:
self.save_batch_data()
# 保存进度
self.save_progress("INTERRUPTED", total_files, processed_count)
return processed_count, failed_count
def export_to_csv(self, output_dir: str):
"""导出为CSV格式"""
os.makedirs(output_dir, exist_ok=True)
# 导出概念
concepts_df = pd.DataFrame(self.concepts)
concepts_df.to_csv(os.path.join(output_dir, "concepts.csv"),
index=False, encoding='utf-8')
# 导出关系
relationships_df = pd.DataFrame(self.relationships)
relationships_df.to_csv(os.path.join(output_dir, "relationships.csv"),
index=False, encoding='utf-8')
print(f"CSV文件已导出到: {output_dir}")
print(f"- 概念数量: {len(self.concepts)}")
print(f"- 关系数量: {len(self.relationships)}")
def export_to_rf2_format(self, output_dir: str):
"""导出为RF2标准格式"""
os.makedirs(output_dir, exist_ok=True)
# 合并所有概念(包括预定义概念)
all_concepts = list(self.predefined_concepts) + self.concepts
# 1. 导出概念文件 (rf2_concept.txt)
concept_headers = ["id", "effectiveTime", "active", "moduleId", "definitionStatusId"]
with open(os.path.join(output_dir, "rf2_concept.txt"), 'w', encoding='utf-8') as f:
f.write('\t'.join(concept_headers) + '\n')
for concept in all_concepts:
f.write(f"{concept['concept_id']}\t20241201\t1\t900000000\t900000000\n")
# 2. 导出描述文件 (rf2_description.txt) - 包含别名
desc_headers = ["id", "effectiveTime", "active", "moduleId", "conceptId",
"languageCode", "typeId", "term", "caseSignificanceId"]
desc_id_counter = 1
with open(os.path.join(output_dir, "rf2_description.txt"), 'w', encoding='utf-8') as f:
f.write('\t'.join(desc_headers) + '\n')
for concept in all_concepts:
concept_id = concept['concept_id']
# 完全限定名 (FSN)
if 'fsn' in concept and concept['fsn']:
f.write(f"D{desc_id_counter:06d}\t20241201\t1\t900000000\t{concept_id}\t"
f"zh-CN\t900000003\t{concept['fsn']}\t900000000\n")
desc_id_counter += 1
# 首选术语 (PT)
if 'preferred_term' in concept and concept['preferred_term']:
f.write(f"D{desc_id_counter:06d}\t20241201\t1\t900000000\t{concept_id}\t"
f"zh-CN\t900000001\t{concept['preferred_term']}\t900000000\n")
desc_id_counter += 1
# 同义词 (Synonyms) - 支持多语言
if 'synonyms' in concept and concept['synonyms']:
for synonym_data in concept['synonyms']:
if isinstance(synonym_data, dict):
# 新格式:包含语言信息
term = synonym_data['term']
lang_code = synonym_data['language_code']
else:
# 兼容旧格式:纯字符串
term = synonym_data
lang_code = "zh-CN" # 默认中文
f.write(f"D{desc_id_counter:06d}\t20241201\t1\t900000000\t{concept_id}\t"
f"{lang_code}\t900000002\t{term}\t900000000\n")
desc_id_counter += 1
# 3. 导出关系文件 (rf2_relationship.txt)
rel_headers = ["id", "effectiveTime", "active", "moduleId", "sourceId",
"destinationId", "relationshipGroup", "typeId", "characteristicTypeId", "modifierId"]
with open(os.path.join(output_dir, "rf2_relationship.txt"), 'w', encoding='utf-8') as f:
f.write('\t'.join(rel_headers) + '\n')
rel_id_counter = 1
for relationship in self.relationships:
f.write(f"R{rel_id_counter:06d}\t20241201\t1\t900000000\t"
f"{relationship['source_id']}\t{relationship['target_id']}\t0\t"
f"{relationship['relationship_type']}\t900000000\t900000000\n")
rel_id_counter += 1
print(f"RF2格式文件已导出到: {output_dir}")
print(f"- rf2_concept.txt: {len(all_concepts)} 个概念")
print(f"- rf2_description.txt: 包含首选术语和同义词")
print(f"- rf2_relationship.txt: {len(self.relationships)} 个关系")
def export_to_neo4j_csv(self, output_dir: str, merge_batches: bool = True):
"""导出为Neo4j导入格式的CSV - 支持合并批次数据"""
os.makedirs(output_dir, exist_ok=True)
# 如果需要合并批次数据
final_concepts = []
final_relationships = []
if merge_batches:
# 先尝试合并所有批次数据
total_concepts, total_relationships = self.merge_all_batches()
# 如果有合并的数据,使用合并后的数据
if total_concepts > 0:
concepts_df = pd.read_csv(os.path.join(output_dir, "concepts.csv"))
final_concepts = concepts_df.to_dict('records')
else:
final_concepts = self.concepts
if total_relationships > 0:
relationships_df = pd.read_csv(os.path.join(output_dir, "relationships.csv"))
final_relationships = relationships_df.to_dict('records')
else:
final_relationships = self.relationships
else:
# 使用当前内存中的数据
final_concepts = self.concepts
final_relationships = self.relationships
# 准备节点数据
nodes_data = []
# 首先添加预定义概念
for predefined_concept in self.predefined_concepts:
node = {
"nodeId": predefined_concept["concept_id"],
"labels": predefined_concept["concept_type"],
"name": predefined_concept["name"],
"preferredTerm": predefined_concept.get("preferred_term", ""),
"fsn": predefined_concept.get("fsn", ""),
"conceptType": predefined_concept["concept_type"],
"synonyms": self._format_synonyms_for_neo4j(predefined_concept.get("synonyms", []))
}
nodes_data.append(node)
# 然后添加动态生成的概念
for concept in final_concepts:
node = {
"nodeId": concept["concept_id"],
"labels": concept["concept_type"],
"name": concept["name"],
"preferredTerm": concept.get("preferred_term", ""),
"category": concept.get("category", ""),
"conceptType": concept["concept_type"],
"synonyms": self._format_synonyms_for_neo4j(concept.get("synonyms", []))
}
# 添加特定类型的属性
if concept["concept_type"] == "Recipe":
node.update({
"difficulty": concept.get("difficulty", ""),
"cuisineType": concept.get("cuisine_type", ""),
"prepTime": concept.get("prep_time", ""),
"cookTime": concept.get("cook_time", ""),
"servings": concept.get("servings", ""),
"tags": concept.get("tags", ""),
"filePath": concept.get("file_path", "")
})
elif concept["concept_type"] == "Ingredient":
node.update({
"amount": concept.get("amount", ""),
"unit": concept.get("unit", ""),
"isMain": concept.get("is_main", "")
})
elif concept["concept_type"] == "CookingStep":
node.update({
"description": concept.get("description", ""),
"stepNumber": concept.get("step_number", ""),
"methods": concept.get("methods", ""),
"tools": concept.get("tools", ""),
"timeEstimate": concept.get("time_estimate", "")
})
nodes_data.append(node)
# 导出节点
nodes_df = pd.DataFrame(nodes_data)
nodes_df.to_csv(os.path.join(output_dir, "nodes.csv"),
index=False, encoding='utf-8')
# 准备关系数据
relationships_data = []
for rel in final_relationships:
relationship = {
"startNodeId": rel["source_id"],
"endNodeId": rel["target_id"],
"relationshipType": rel["relationship_type"],
"relationshipId": rel["relationship_id"]
}
# 添加额外属性
for key, value in rel.items():
if key not in ["source_id", "target_id", "relationship_type", "relationship_id"]:
relationship[key] = value
relationships_data.append(relationship)
# 导出关系
relationships_df = pd.DataFrame(relationships_data)
relationships_df.to_csv(os.path.join(output_dir, "relationships.csv"),
index=False, encoding='utf-8')
# 生成Neo4j导入脚本
import_script = f"""
// Neo4j 数据导入脚本
// 导入节点
LOAD CSV WITH HEADERS FROM 'file:///nodes.csv' AS row
CREATE (n:Concept)
SET n.nodeId = row.nodeId,
n.name = row.name,
n.preferredTerm = row.preferredTerm,
n.category = row.category,
n.conceptType = row.conceptType,
n.difficulty = toInteger(row.difficulty),
n.cuisineType = row.cuisineType,
n.prepTime = row.prepTime,
n.cookTime = row.cookTime,
n.servings = row.servings,
n.tags = row.tags,
n.filePath = row.filePath,
n.amount = row.amount,
n.unit = row.unit,
n.isMain = toBoolean(row.isMain),
n.description = row.description,
n.stepNumber = toInteger(row.stepNumber),
n.methods = row.methods,
n.tools = row.tools,
n.timeEstimate = row.timeEstimate;
// 创建索引
CREATE INDEX concept_id_index IF NOT EXISTS FOR (c:Concept) ON (c.nodeId);
CREATE INDEX concept_name_index IF NOT EXISTS FOR (c:Concept) ON (c.name);
CREATE INDEX concept_category_index IF NOT EXISTS FOR (c:Concept) ON (c.category);
// 导入关系
LOAD CSV WITH HEADERS FROM 'file:///relationships.csv' AS row
MATCH (start:Concept {{nodeId: row.startNodeId}})
MATCH (end:Concept {{nodeId: row.endNodeId}})
CALL apoc.create.relationship(start, row.relationshipType, {{
relationshipId: row.relationshipId,
amount: row.amount,
unit: row.unit,
stepOrder: toInteger(row.step_order)
}}, end) YIELD rel
RETURN count(rel);
"""
with open(os.path.join(output_dir, "neo4j_import.cypher"), 'w', encoding='utf-8') as f:
f.write(import_script)
print(f"Neo4j CSV文件已导出到: {output_dir}")
print(f"- 节点数量: {len(nodes_data)}")
print(f"- 关系数量: {len(relationships_data)}")
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description='使用AI智能解析菜谱生成知识图谱')
parser.add_argument('recipe_dir', help='菜谱目录路径')
parser.add_argument('-k', '--api-key', required=True, help='Kimi API密钥')
parser.add_argument('-o', '--output', default='./ai_output', help='输出目录路径')
parser.add_argument('--format', choices=['csv', 'neo4j'], default='neo4j',
help='输出格式 (csv 或 neo4j)')
parser.add_argument('--base-url', default='https://api.moonshot.cn/v1',
help='Kimi API基础URL')
args = parser.parse_args()
# 检查输入目录
if not os.path.exists(args.recipe_dir):
print(f"错误: 菜谱目录不存在 - {args.recipe_dir}")
return
# 创建AI agent
print("初始化Kimi AI Agent...")
ai_agent = KimiRecipeAgent(args.api_key, args.base_url)
# 创建知识图谱构建器
builder = RecipeKnowledgeGraphBuilder(ai_agent, args.output)
# 批量处理菜谱
print(f"开始处理菜谱目录: {args.recipe_dir}")
processed, failed = builder.batch_process_recipes(args.recipe_dir)
print(f"\n处理完成:")
print(f"- 成功处理: {processed} 个菜谱")
print(f"- 处理失败: {failed} 个菜谱")
# 导出数据
if args.format == 'neo4j':
builder.export_to_neo4j_csv(args.output)
else:
builder.export_to_csv(args.output)
if __name__ == "__main__":
# 测试用例
if len(os.sys.argv) == 1:
print("AI菜谱解析器测试模式")
print("请提供Kimi API密钥和菜谱目录路径")
print("使用方法:")
print("python recipe_ai_agent.py /path/to/recipes -k YOUR_API_KEY")
else:
main()