diff --git a/.gitignore b/.gitignore index eb9b5cd5e5f88f017787b4f7001974daeea359f2..9393eb68b166f6fb575f8cc0b7f6a320817ab647 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ deepinsight.db-journal data/ charts/ .DS_Store +logs/ +*.log diff --git a/README.md b/README.md index 0cf67355b43560a4fa1ab5bce26bf9e1fbd96b38..a897f1527c9e65550e5f2cdb4a3b84487975287b 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,49 @@ poetry run alembic upgrade head - 会议管理(conference) - 列表:`deepinsight conference list` - 删除:`deepinsight conference remove --id 12` - - 生成知识库:`deepinsight conference generate --name "ICLR 2025" --files-src ./path/to/files` + - 顶会洞察:`deepinsight conference generate --name "ICLR 2025" --files-src ./path/to/files` + - 会议问答:`deepinsight conference qa --name "ICLR 2025" --files-src ./path/to/files --question "今年最佳论文有哪些创新点?"` - 深度研究助手(research) - 启动研究:`deepinsight research start --topic "人工智能发展趋势"` - 查看帮助:`deepinsight research --help` +- 后端服务(api) + - 启动后端服务:`deepinsight api start --config ./config.yaml` + - 指定专家配置(可选):`deepinsight api start --config ./config.yaml --expert-config ./experts.yaml` + - 也可通过环境变量指定:`DEEPINSIGHT_CONFIG=./config.yaml deepinsight api start` + 提示:可通过环境变量 `DEEPINSIGHT_CONFIG` 指定配置文件路径(默认 `./config.yaml`)。 +### 图表图片路径配置(image_path_mode & image_base_url) +- 在 `config.yaml` 的 `workspace` 段控制图表图片 URL 的返回策略: + - `image_path_mode`: `relative` 或 `base_url` + - `image_base_url`: 当使用 `base_url` 模式时用于拼接的基础 URL(例如 `http://127.0.0.1:8888/api/v1/deepinsight/charts/image`)。 +- 推荐设置: + - 命令行/离线生成 PDF 与 Markdown:`image_path_mode: relative` + - API/Web 预览:`image_path_mode: base_url` 并设置 `image_base_url` 指向你的服务地址。 +- 返回示例: + - `relative` → `../../charts/.png` + - `base_url` → `http://:/api/v1/deepinsight/charts/image/` +- 配置示例: + ```yaml + workspace: + work_root: ./data + chart_image_dir: charts + image_path_mode: base_url + image_base_url: http://127.0.0.1:8888/api/v1/deepinsight/charts/image + ``` + 若在命令行模式,请将 `image_path_mode` 设为 `relative`,其余保持默认即可。 + ### 方式二:Web方式运行 #### 启动后端服务 ``` poetry install -python deepinsight/app.py +deepinsight api start --config ./config.yaml +# 或直接运行脚本: +python deepinsight/api/app.py --config ./config.yaml ``` #### 启动前端服务 diff --git a/config.yaml b/config.yaml index 81bc650cda2ac73dc49118b465ac84ebe9554541..31ed87d7c5e725556ccc271b378e1c28a64630bb 100644 --- a/config.yaml +++ b/config.yaml @@ -22,8 +22,12 @@ workspace: work_root: ./data # 会议洞察报告 PPT 模板路径(支持环境变量展开,如 ${PPT_TEMPLATE_PATH}) conference_ppt_template_path: ./templates/conference_template.pptx - # 图表图片保存目录(相对 work_root) chart_image_dir: charts + # 使用相对路径还是 base_url 模式返回图表图片 URL + # - relative: 直接返回图片相对路径,适合本地命令行,如:/deepinsight/charts/image/chart_123.png + # - base_url: 拼接 base_url,适合api模式,如:http://127.0.0.1:8888/api/v1/deepinsight/charts/image/chart_123.png + image_path_mode: base_url + image_base_url: http://127.0.0.1:8888/api/v1/deepinsight/charts/image # RAG 相关工作路径配置 # 将作为所有 RAG 本地数据的前缀目录,例如: @@ -42,6 +46,8 @@ prompt_management: groups: deep_research: label: "latest" + conference_qa: + label: "latest" conference_supervisor: label: "latest" conference_overview: diff --git a/deepinsight/api/app.py b/deepinsight/api/app.py index 1421566e742cb508cf8965c98dbbfe6f6d3499bd..fd2305d0f808bf196935fc75a284e409297d592b 100755 --- a/deepinsight/api/app.py +++ b/deepinsight/api/app.py @@ -8,118 +8,241 @@ # MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. # See the Mulan PSL v2 for more details. -import asyncio +import argparse +import logging import os -import uuid -from datetime import datetime -from typing import Optional, Dict - -from fastapi import FastAPI, Request, APIRouter, HTTPException, Query, Body, Depends -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse - -from deepinsight.service.conversation import ConversationService -from deepinsight.service.deep_research import MessageType, DeepResearchService -from deepinsight.service.schemas.conversation import (ConversationListRsp, ConversationListMsg, ConversationListItem, - AddConversationRsp, BodyAddConversation, AddConversationMsg, - ResponseData, DeleteConversationData, RenameConversationData, - BodyGetList) -from deepinsight.service.schemas.chat import GetChatHistoryData, GetChatHistoryStructure, GetChatHistoryRsp - -# 读取环境变量中的 API 前缀 -API_PREFIX = os.getenv("API_PREFIX", "") - -# 创建 FastAPI 实例 -app_instance = FastAPI( - title="DeepInsight API", - description="A streaming chat API for DeepInsight", - version="1.0.0" +import re +from pathlib import Path +from fastapi.responses import StreamingResponse, JSONResponse +from typing import Optional +from urllib.parse import quote + +import dotenv +import uvicorn +from fastapi import FastAPI, APIRouter, Header +from fastapi.responses import HTMLResponse +from fastapi.responses import FileResponse +from starlette import status + +from deepinsight.config.config import load_config +from deepinsight.service.research.research import ResearchService +from deepinsight.service.conference.paper_extractor import PaperExtractionService, PaperParseException +from deepinsight.utils.log_utils import initRootLogger +from deepinsight.core.utils.research_utils import load_expert_config +from deepinsight.service.schemas.common import ResponseModel +from deepinsight.service.schemas.research import ResearchRequest, PPTGenerateRequest, PdfGenerateRequest +from deepinsight.service.schemas.paper_extract import ExtractPaperMetaRequest + +dotenv.load_dotenv(override=True) +initRootLogger("deepinsight") + +DEFAULT_CONFIG_PATH = str(Path(__file__).resolve().parent.parent.parent / 'config.yaml') +DEFAULT_EXPERT_PATH = str(Path(__file__).resolve().parent.parent.parent / 'experts.yaml') + +parser = argparse.ArgumentParser(description="Start DeepInsight API server") +parser.add_argument( + "--config", + type=str, + default=DEFAULT_CONFIG_PATH, + help="Path to config.yaml file" ) -_conversations: Dict[str, ConversationListItem] = {} -# 创建路由 -router = APIRouter() - -# 跨域中间件配置 -app_instance.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], +parser.add_argument( + "--expert_config", + type=str, + default=DEFAULT_EXPERT_PATH, + help="Path to config.yaml file" ) +args = parser.parse_args() +config = load_config(args.config) +research_service = ResearchService(config) +paper_extract_service = PaperExtractionService(config) +# 加载专家数据 +experts = load_expert_config(args.expert_config) +router = APIRouter(tags=["deepinsight"]) -@router.get("/api/conversations", response_model=ConversationListRsp, tags=["conversation"]) -async def get_conversation_list(body: BodyGetList = Depends()): - try: - conversation_list = ConversationService.get_list(user_id=body.user_id, offset=body.offset, limit=body.limit) - return ConversationListRsp( - code=0, - message="OK", - data=ConversationListMsg(conversations=conversation_list) - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) +@router.get("/health", response_model=ResponseModel[str]) +async def health(): + return ResponseModel(data="healthy") + +@router.get("/deepinsight/charts/image/{file_id}") +async def show_chart_image(file_id: str): + """ + 返回对应的 PNG 图片文件 + """ + safe_pattern = re.compile(r"[\d\w:/-]+") + safe_file_id = "".join(safe_pattern.findall(file_id)) + + chart_dir = os.path.abspath(os.path.join(config.workspace.work_root, config.workspace.chart_image_dir)) + file_name = f"{safe_file_id}.png" + file_path = os.path.abspath(os.path.join(chart_dir, file_name)) + logging.debug(f"image_path: {file_path} {os.path.exists(file_path)}") + + if not os.path.exists(file_path): + return get_json_result(code=100, message="image file not found") -# temporarily deprecated -@router.post("/api/conversation", response_model=AddConversationRsp, tags=["conversation"]) -async def add_conversation( - body: BodyAddConversation = Body(...) -): try: - new_conversation = ConversationService.add_conversation(user_id=body.user_id, title=body.title, - conversation_id=body.conversation_id) - - return AddConversationRsp( - code=0, - message="OK", - data=AddConversationMsg(conversationId=str(new_conversation.conversation_id), - created_time=str(new_conversation.created_time)) - ) + # FileResponse 会自动设置正确的 Content-Type (image/png) + return FileResponse(file_path, media_type="image/png", headers={"Cache-Control": "max-age=120"}) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return get_json_result(code=100, message=repr(e)) -@router.delete("/api/conversation", response_model=ResponseData, tags=["conversation"]) -async def delete_conversation(data: DeleteConversationData = Body(...)): - try: - for cid in data.conversation_list: - ConversationService.del_conversation(conversation_id=cid) - return ResponseData(code=0, message="Deleted", data={}) +@router.get('/deepinsight/charts/{file_id}') +async def show_chart(file_id: str): + safe_pattern = re.compile(r"[\d\w:/-]+") + safe_file_id = "".join(safe_pattern.findall(file_id)) + chart_dir = os.path.abspath(os.path.join(config.workspace.work_root, config.workspace.chart_image_dir)) + file_name = f"{safe_file_id}.html" + file_path = os.path.abspath(os.path.join(chart_dir, file_name)) + logging.debug(f"file_path: {file_path} {os.path.exists(file_path)}") + + if not os.path.exists(file_path): + return get_json_result(code=100, message="file not found") + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + return HTMLResponse(content=content, headers={"Cache-Control": "max-age=120"}) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return get_json_result(code=100, message=repr(e)) + + +def get_json_result(code=0, message="success", data=None): + response = {"code": code, "message": message, "data": data} + return JSONResponse(content=response) + + +@router.post("/deepinsight/chat") +async def deepinsight_chat(request: ResearchRequest): + """ + Async endpoint for insight. + """ + logging.info(f"request: {request}") + + async def stream(): + async for event in research_service.chat(request=request): + yield f"data: {event.model_dump_json()}\n\n" + return StreamingResponse(stream(), media_type="text/event-stream") -@router.put("/api/conversation", response_model=ResponseData, tags=["conversation"]) -async def rename_conversation(data: RenameConversationData = Body(...)): + +@router.post("/deepinsight/paper/parse") +async def parse_paper_meta(request: ExtractPaperMetaRequest): + """Parse metadata (title, author, abstract, keywords and number of sections) from a paper in Markdown format.""" try: - conversation, is_succeed = ConversationService.rename_conversation(conversation_id=data.conversation_id, - new_name=data.new_name) - if is_succeed: - return ResponseData(code=0, message="Modified", data={"new_name": data.new_name}) + return await paper_extract_service.extract_and_store(request) + except PaperParseException as e: + return dict(error=str(e)) + + +@router.get("/deepinsight/experts") +async def get_experts(type: Optional[str] = None): + """ + 获取专家信息,按类型分组返回专家名字列表。 + - `type` 参数可选,用于过滤专家类型(reviewer 或 writer)。 + """ + # 按类型分组专家名字 + experts_by_type = {} + for expert in experts: + if expert.type not in experts_by_type: + experts_by_type[expert.type] = [] + experts_by_type[expert.type].append({"prompt_key": expert.prompt_key, "name": expert.name}) + + # 如果提供了 type 参数,返回该类型下的专家名字列表 + if type: + if type in experts_by_type: + return ResponseModel(data={type: experts_by_type[type]}) else: - return ResponseData(code=100, message="Conversation Not Found", data={"new_name": data.new_name}) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/conversations/{conversation_id}/messages", response_model=GetChatHistoryRsp, tags=["conversation"]) -async def get_conversation_messages(conversation_id: str): - conversation_info = ConversationService.get_conversation_info(conversation_id) - history_present = ConversationService.get_history_messages(conversation_id) - new_data = GetChatHistoryStructure( - conversation_id=conversation_id, - user_id=conversation_info.user_id, - created_time=str(conversation_info.created_time), - title=conversation_info.title, - status=conversation_info.status, - messages=history_present + return get_json_result(code=404, message=f"No experts found for type: {type}", data=None) + + # 否则返回所有类型的专家名字列表 + return ResponseModel(data=experts_by_type) + + + +@router.post("/deepinsight/ppt/generate") +async def ppt_generate(request: PPTGenerateRequest): + pptx_stream, output_name = await research_service.ppt_generate(request=request) + output_name = output_name.split("/")[-1] + encoded_file_name = quote(output_name) + # 返回文件流 + return StreamingResponse( + pptx_stream, + media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + headers={"Content-Disposition": f"attachment; filename={encoded_file_name}"} ) - return GetChatHistoryRsp(code=0, message="ok", data=new_data) -app_instance.include_router(router, prefix=API_PREFIX) +@router.post("/deepinsight/pdf/generate") +async def pdf_generate(request: PdfGenerateRequest): + try: + pdf_stream, output_name = await research_service.pdf_generate(request=request) + + # 文件名安全处理 + output_name = output_name.split("/")[-1] + encoded_file_name = quote(output_name) + + return StreamingResponse( + pdf_stream, + media_type="text/pdf; charset=utf-8", + headers={ + "Content-Disposition": f"attachment; filename={encoded_file_name}", + "Content-Type": "application/octet-stream", + }, + ) + + except FileNotFoundError as e: + logging.error(f"{str(e)}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "code": status.HTTP_400_BAD_REQUEST, + "message": f"生成失败,部分源文件未找到:{str(e)}", + "data": None + } + ) + + except ValueError as e: + logging.error(f"{str(e)}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "code": status.HTTP_400_BAD_REQUEST, + "message": f"生成失败,数据格式错误:{str(e)}", + "data": None + } + ) + + except Exception as e: + logging.error(f"{str(e)}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "code": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": f"服务器内部错误,请稍后重试:{str(e)}", + "data": None + } + ) + + +app = FastAPI(title="DeepInsight API") + +app.include_router(router, prefix=config.app.api_prefix) + +if __name__ == "__main__": + for route in app.routes: + from fastapi.routing import APIRoute + + if isinstance(route, APIRoute): + print(f"路径: {route.path}, 方法: {route.methods}, 名称: {route.name}") + uvicorn.run( + app, + host=config.app.host, + port=config.app.port, + reload=config.app.reload, + ) diff --git a/deepinsight/cli/commands/api.py b/deepinsight/cli/commands/api.py new file mode 100644 index 0000000000000000000000000000000000000000..dacb4c3a794f4fce71586546aee55e05cfcb6a15 --- /dev/null +++ b/deepinsight/cli/commands/api.py @@ -0,0 +1,84 @@ +""" +API Server Command + +Provides a CLI subcommand to start the DeepInsight backend API server. +""" + +import argparse +import os +import sys +import subprocess +from typing import Optional + + +class ApiCommand: + """CLI command handler for starting the backend API server.""" + + def __init__(self): + self.version = "1.0.0" + + def _create_parser(self) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="deepinsight api", + description="Start the DeepInsight backend API server", + ) + subparsers = parser.add_subparsers(dest="subcommand", help="Operations") + + start_parser = subparsers.add_parser("start", help="Start API server") + start_parser.add_argument( + "--config", + type=str, + required=False, + help="Path to config.yaml (defaults to ./config.yaml or $DEEPINSIGHT_CONFIG)", + ) + start_parser.add_argument( + "--expert-config", + type=str, + required=False, + help="Path to experts.yaml (optional)", + ) + start_parser.add_argument( + "--env", + action="append", + default=[], + help="Extra environment variables in KEY=VALUE form (can be repeated)", + ) + return parser + + def execute(self, args: argparse.Namespace) -> int: + parser = self._create_parser() + parsed = parser.parse_args(sys.argv[2:]) + + if parsed.subcommand != "start": + parser.print_help() + return 1 + + return self._handle_start(parsed) + + def _handle_start(self, args: argparse.Namespace) -> int: + # Resolve config and expert paths + cfg_path: Optional[str] = args.config or os.getenv("DEEPINSIGHT_CONFIG") + # Build the command to run the API module as a script + cmd = [sys.executable, os.path.join("deepinsight", "api", "app.py")] + if cfg_path: + cmd.extend(["--config", cfg_path]) + if getattr(args, "expert_config", None): + cmd.extend(["--expert_config", args.expert_config]) + + # Prepare environment: propagate current env and optional overrides + env = os.environ.copy() + for kv in args.env or []: + if "=" in kv: + k, v = kv.split("=", 1) + env[k] = v + + try: + # Start the server in the foreground; user can Ctrl-C to stop. + proc = subprocess.Popen(cmd, env=env) + proc.wait() + return proc.returncode or 0 + except KeyboardInterrupt: + return 130 + except Exception as e: + print(f"Failed to start API server: {e}") + return 1 \ No newline at end of file diff --git a/deepinsight/cli/commands/conference.py b/deepinsight/cli/commands/conference.py index 7574b683ef765881897d3e51530ecd62e46bb477..42821ff69b3607d2f722c407810ca7ee1f9447a2 100644 --- a/deepinsight/cli/commands/conference.py +++ b/deepinsight/cli/commands/conference.py @@ -28,7 +28,8 @@ from deepinsight.service.schemas.conference import ( from deepinsight.service.research.research import ResearchService from deepinsight.service.schemas.research import ResearchRequest, SceneType, PPTGenerateRequest -from deepinsight.cli.commands.stream import run_research_and_save_report_sync +from deepinsight.service.schemas.streaming import Message, MessageContent, MessageContentType +from deepinsight.cli.commands.stream import run_research_and_save_report_sync, make_report_filename from deepinsight.core.types.graph_config import SearchAPI @@ -55,6 +56,8 @@ class ConferenceCommand: return self._handle_remove(conf_args) elif conf_args.subcommand == 'generate': return self._handle_generate(conf_args) + elif conf_args.subcommand == 'qa': + return self._handle_qa(conf_args) else: parser.print_help() return 1 @@ -62,7 +65,15 @@ class ConferenceCommand: def _create_parser(self) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog='deepinsight conference', - description='Conference information management (generate/list/delete)' + description='Conference information management (list/remove/generate/qa)', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog='''\ +Examples: + deepinsight conference list + deepinsight conference generate --name "ICLR 2025" --files-src ./docs + deepinsight conference qa --name "ICLR 2025" --files-src ./docs --question "今年最佳论文有哪些创新点?" + deepinsight conference remove --id 12 + ''' ) subparsers = parser.add_subparsers(dest='subcommand', help='Operations') @@ -87,6 +98,10 @@ class ConferenceCommand: # Note: --files-src replaces the previous --docs-src for clarity. generate_parser.add_argument('--name', '-n', required=True, help='Conference name including year, e.g., "ICLR 2025"') generate_parser.add_argument('--files-src', '-f', required=True, help='User-provided source directory of files to ingest') + qa_parser = subparsers.add_parser('qa', help='Conference QA based on ingested documents') + qa_parser.add_argument('--name', '-n', required=True, help='Conference name including year, e.g., "ICLR 2025"') + qa_parser.add_argument('--files-src', '-f', required=True, help='User-provided source directory of files to ingest') + qa_parser.add_argument('--question', '-q', required=True, help='User question to answer against the conference knowledge base') return parser def _get_config(self): @@ -182,8 +197,13 @@ class ConferenceCommand: service=research_service, request=ResearchRequest( conversation_id=conv_id, - query=query, - scene_type=SceneType.CONFERENCE, + messages=[ + Message( + content=MessageContent(text=query), + content_type=MessageContentType.plain_text, + ) + ], + scene_type=SceneType.CONFERENCE_RESEARCH, allow_user_clarification=False, allow_edit_research_brief=False, allow_edit_report_outline=False, @@ -215,4 +235,65 @@ class ConferenceCommand: return 0 except Exception as e: logger.exception("✗ 生成失败") + return 1 + + def _handle_qa(self, args: argparse.Namespace) -> int: + try: + service = self._get_service() + name = args.name.strip() + m = re.search(r'(19|20)\d{2}', name) + if not m: + raise ValueError('No year detected in name, please include a four-digit year like 2025') + year = int(m.group(0)) + full_name = name + base_name_no_year = re.sub(r'[\s\(\[\{,]*' + m.group(0) + r'[\s\)\]\},]*', ' ', name).strip() + if not base_name_no_year: + base_name_no_year = name.replace(m.group(0), '').strip() + short_name = None + compact = re.sub(r'\s+', '', base_name_no_year) + if compact.isupper() and 2 <= len(compact) <= 12: + short_name = compact + req = ConferenceParseDocsRequest( + short_name=short_name, + full_name=full_name, + year=year, + docs_src_dir=args.files_src, + ) + reporter = RichProgressReporter(console=get_console()) + reporter.info("Parsing documents. This may take a while...") + asyncio.run(service.ensure_conference_and_ingest_docs(req, reporter=reporter)) + research_service = ResearchService(self._get_config()) + base = (short_name or full_name).strip() + slug = re.sub(r"\s+", "-", base) + conv_id = f"qa-{slug}-{year}" + query = args.question.strip() + # 生成带有问题前缀与时间戳的唯一文件名,避免同会议不同问题的报告相互覆盖 + result_stem = make_report_filename( + question=query, + expert=f"conference_qa_{(short_name or base_name_no_year).strip()}_{year}", + ) + run_research_and_save_report_sync( + service=research_service, + request=ResearchRequest( + conversation_id=conv_id, + messages=[ + Message( + content=MessageContent(text=query), + content_type=MessageContentType.plain_text, + ) + ], + scene_type=SceneType.CONFERENCE_QA, + allow_user_clarification=False, + allow_edit_research_brief=False, + allow_edit_report_outline=False, + search_api=[SearchAPI.TAVILY], + ), + result_file_stem=result_stem, + gen_pdf=True, + live=Live(console=get_console()), + ) + print("✓ 问答完成(Markdown/PDF)") + return 0 + except Exception as e: + logger.exception("✗ 问答失败") return 1 \ No newline at end of file diff --git a/deepinsight/cli/commands/research.py b/deepinsight/cli/commands/research.py index 53834aab1330f1f48e350b1e32ce3d48d03f3494..34153c6cc3f6cf84ed63a0200d768751c380fc26 100644 --- a/deepinsight/cli/commands/research.py +++ b/deepinsight/cli/commands/research.py @@ -15,6 +15,7 @@ from deepinsight.config.config import load_config from deepinsight.config.config import Config from deepinsight.service.research.research import ResearchService from deepinsight.service.schemas.research import ResearchRequest, SceneType +from deepinsight.service.schemas.streaming import Message, MessageContent, MessageContentType from deepinsight.core.types.graph_config import SearchAPI from deepinsight.cli.commands.stream import ( run_research_and_save_report_sync, @@ -119,7 +120,12 @@ def run_generate_report( def create_one_generate(expert_name): return ResearchRequest( conversation_id=conversation_id, - query=question, + messages=[ + Message( + content=MessageContent(text=question), + content_type=MessageContentType.plain_text, + ) + ], scene_type=SceneType.DEEP_RESEARCH, search_api=search_types, expert_review_enable=False, @@ -161,7 +167,7 @@ def run_generate_report( for each in report_filenames: with open(get_with_md_file_name(each, conversation_id, "research_result"), "r", encoding="utf-8") as f: all_sub_reports.append(f.read()) - models, default_model = init_langchain_models_from_llm_config(insight_service.config.llms) + models, default_model = init_langchain_models_from_llm_config(insight_service.get_default_config()) summary_prompt = ( PromptManager(insight_service.config.prompt_management) .get_prompt(name="summary_prompt", group="summary_experts") @@ -227,7 +233,7 @@ def run_expert_review(question: str, insight_service: ResearchService, conversat with open(real_name, "r", encoding="utf-8") as f: question = f.read() expert_names = choose_expert(require_one=True, expert_type="reviewer", live=live) - models, default_model = init_langchain_models_from_llm_config(insight_service.config.llms) + models, default_model = init_langchain_models_from_llm_config(insight_service.get_default_config()) export_review_subgraph = build_expert_review_graph( [ExpertDef(name=each, prompt_key=each, type="reviewer") for each in expert_names] ) @@ -317,4 +323,4 @@ def run_insight(config: Config, gen_pdf: bool = True, initial_topic: str | None conversation_id=conversation_id, live=live, ) - return 0 \ No newline at end of file + return 0 diff --git a/deepinsight/cli/commands/stream.py b/deepinsight/cli/commands/stream.py index 340602c4e79ab69cb7e0f96ddba681129b2a40e1..438fcc28ed20f9837589e0a20bdc802c9eb9ceaa 100644 --- a/deepinsight/cli/commands/stream.py +++ b/deepinsight/cli/commands/stream.py @@ -36,6 +36,8 @@ from deepinsight.service.schemas.streaming import ( EventType, MessageToolCallContent, MessageContentType, + Message, + MessageContent, ) # ANSI escape helpers @@ -518,7 +520,12 @@ async def _process_request(service: ResearchService, request: ResearchRequest, l live.stop() user_input = await ask_user(prompt_text=prompt_text, mode=stream_event.event, live=live) new_request = deepcopy(request) - new_request.query = user_input + new_request.messages = [ + Message( + content=MessageContent(text=user_input), + content_type=MessageContentType.plain_text, + ) + ] try: await agen.aclose() except Exception: diff --git a/deepinsight/cli/main.py b/deepinsight/cli/main.py index 09f867e210aff58decd87b1d9565829c6a6ef856..abcc94a535adc02b4218b1afb135a56905f3a606 100644 --- a/deepinsight/cli/main.py +++ b/deepinsight/cli/main.py @@ -18,6 +18,7 @@ from rich import get_console from deepinsight.cli.commands.research import ResearchCommand from deepinsight.cli.commands.conference import ConferenceCommand +from deepinsight.cli.commands.api import ApiCommand dotenv.load_dotenv(override=True) @@ -52,6 +53,7 @@ class DeepInsightCLI: 'research': ResearchCommand(), 'conference': ConferenceCommand(), + 'api': ApiCommand(), } def _create_parser(self) -> argparse.ArgumentParser: @@ -64,6 +66,7 @@ class DeepInsightCLI: Examples: deepinsight conference list deepinsight conference generate --name "ICLR 2025" --files-src ./docs + deepinsight conference qa --name "ICLR 2025" --files-src ./docs --question "今年最佳论文有哪些创新点?" deepinsight research start deepinsight --version @@ -118,6 +121,18 @@ For more information on a specific command, run: nargs=argparse.REMAINDER, help='Arguments for conference subcommands (parsed by ConferenceCommand)' ) + + # API server command + api_parser = subparsers.add_parser( + 'api', + help='Start backend API server', + description='Start DeepInsight backend API service' + ) + api_parser.add_argument( + 'args', + nargs=argparse.REMAINDER, + help='Arguments for api subcommands (parsed by ApiCommand)' + ) return parser def run(self, args: Optional[List[str]] = None) -> int: @@ -138,6 +153,11 @@ For more information on a specific command, run: if not rest or '--help' in rest or '-h' in rest: ResearchCommand()._create_parser().print_help() return 0 + if parsed_args.command == 'api': + rest = getattr(parsed_args, 'args', []) + if not rest or '--help' in rest or '-h' in rest: + ApiCommand()._create_parser().print_help() + return 0 # Get the appropriate command handler command = self.commands.get(parsed_args.command) @@ -167,4 +187,4 @@ def main() -> int: if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/deepinsight/config/llm_config.py b/deepinsight/config/llm_config.py index f41899d6590a31b20ba3c7fb4ed0538f6adce6b8..b0ad5943130f23e0ac02cf27a9aff568397f777b 100644 --- a/deepinsight/config/llm_config.py +++ b/deepinsight/config/llm_config.py @@ -22,7 +22,7 @@ class LLMConfig(BaseModel): - setting: 生成参数(LLMSetting,可选) """ - type: str = Field(..., description="LLM provider, e.g., openai, deepseek, anthropic") + type: Optional[str] = Field(None, description="LLM provider, e.g., openai, deepseek, anthropic") model: str = Field(..., description="Model name, e.g., gpt-4") base_url: Optional[str] = Field(None, description="Model API base URL") api_key: Optional[str] = Field(None, description="Model API key") diff --git a/deepinsight/config/workspace_config.py b/deepinsight/config/workspace_config.py index 2113d1e290490a06b68d9e7b83d8153f8c74d46c..144de0a60b482f97245b1369456151dc64b52f83 100644 --- a/deepinsight/config/workspace_config.py +++ b/deepinsight/config/workspace_config.py @@ -16,6 +16,16 @@ class WorkspaceConfig(BaseModel): description="Relative image save directory under work_root", ) + image_base_url: str | None = Field( + default=None, + description="Base URL for serving chart images", + ) + + image_path_mode: str = Field( + default="relative", + description="Path mode for image return: relative | base_url", + ) + conference_ppt_template_path: str | None = Field( default=None, description="PPT 模板路径(用于会议洞察报告生成)", diff --git a/deepinsight/core/agent/conference_qa/__init__.py b/deepinsight/core/agent/conference_qa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/deepinsight/core/agent/conference_qa/statistics.py b/deepinsight/core/agent/conference_qa/statistics.py new file mode 100644 index 0000000000000000000000000000000000000000..876517aa6806054a87cab9c38de7f458128ee570 --- /dev/null +++ b/deepinsight/core/agent/conference_qa/statistics.py @@ -0,0 +1,52 @@ +import asyncio +import json +import sys +from typing import Literal, TypedDict, Optional + +from langchain_core.messages import HumanMessage +from langchain.agents import create_agent +from langchain_experimental.tools import PythonREPLTool +from langgraph.graph import StateGraph, MessagesState, START, END +from langgraph.types import Command + +from deepinsight.core.utils.research_utils import parse_research_config +from deepinsight.utils.db_schema_utils import get_db_models_source_markdown + +class State(MessagesState): + next_step: str + + +def create_statistic_agent(llm, config): + rc = parse_research_config(config) + + system_prompt = rc.prompt_manager.get_prompt( + name="static_agent_system_prompt", + group=rc.prompt_group, + ).format(db_models_description=get_db_models_source_markdown()) + agent = create_agent( + model=llm, + tools=[PythonREPLTool()], + system_prompt=system_prompt + ) + + return agent + + +async def statistic_agent_node(state: State, config) -> Command[Literal[END]]: + rc = parse_research_config(config) + statistic_agent = create_statistic_agent(llm=rc.get_model(), config=config) + result = await statistic_agent.ainvoke(state, config=config) + return Command( + update={ + "messages": [ + HumanMessage(content=result["messages"][-1].content, name="statistic_agent") + ] + }, + goto=END, + ) + + +graph_builder = StateGraph(State) +graph_builder.add_node("statistic_agent", statistic_agent_node) +graph_builder.add_edge(START, "statistic_agent") +graph = graph_builder.compile() diff --git a/deepinsight/core/agent/conference_qa/supervisor.py b/deepinsight/core/agent/conference_qa/supervisor.py new file mode 100644 index 0000000000000000000000000000000000000000..b0dbdccf5b1024432453ddb84bd12d57fe6d408c --- /dev/null +++ b/deepinsight/core/agent/conference_qa/supervisor.py @@ -0,0 +1,409 @@ +import logging +import os +from enum import Enum +from typing import Any, TypedDict, Literal, Annotated, List + +from langchain.agents import create_agent +from langchain.agents.middleware import TodoListMiddleware +from langchain_core.messages import BaseMessage, HumanMessage +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.config import get_stream_writer +from langgraph.constants import END +from langgraph.graph import StateGraph, add_messages +from langgraph.types import Command, interrupt +from langmem.short_term import SummarizationNode + +from deepinsight.core.tools.ragflow_retrival import KnowledgeTool +from deepinsight.core.utils.progress_utils import progress_stage +from deepinsight.utils.tavily_key_utils import select_api_key +from deepinsight.core.utils.research_utils import parse_research_config +from deepinsight.core.types.research import FinalResult +from deepinsight.core.agent.conference_research.supervisor import graph as conference_research_graph +from deepinsight.core.agent.conference_qa.statistics import graph as statistics_graph +from deepinsight.core.tools.tavily_search import tavily_search +from deepinsight.core.tools.wordcloud_tool import generate_wordcloud +from deepinsight.service.schemas.research import SceneType +from integrations.mcps.generate_chart import generate_area_chart, generate_bar_chart, generate_column_chart, \ + generate_pie_chart, generate_scatter_chart, generate_line_chart, generate_radar_chart + + +class GraphNodeType(str, Enum): + SUMMARIZER = "summarizer" # 对话摘要节点 + SUPERVISOR = "supervisor" # 监督者节点(任务分配) + ANSWER_COMPOSER = "answer_composer" # 答案汇总节点 + CLARIFY_NODE = "question_clarify" # 问题澄清节点 + PAPER_TEAM = "paper_team" # 论文团队节点 + RETRIVAL_TEAM = "retrival_team" # 检索团队节点 + DEEP_RESEARCH_TEAM = "deep_research_team" # 深度研究团队节点 + CHART_NODE = "chart_node" # 报告(图表)团队节点 + + +# 定义状态格式 +class SupervisorState(TypedDict): + messages: Annotated[List[BaseMessage], add_messages] + context: dict[str, Any] + + +async def summarization_node(state: SupervisorState, config): + rc = parse_research_config(config) + summarizer = SummarizationNode( + model=rc.get_model(), + max_tokens=32768, + max_tokens_before_summary=2048, + max_summary_tokens=16384, + output_messages_key="messages" + ) + result = await summarizer.ainvoke(state) + return result + + +@progress_stage("生成回复") +async def answer_composer_node(state: SupervisorState, config): + rc = parse_research_config(config) + prompt_template = rc.prompt_manager.get_prompt( + name="answer_composer_prompt", + group=rc.prompt_group, + ) + system_prompt = prompt_template.format( + messages=state["messages"] + ) + + response = await rc.get_model().ainvoke([ + {"role": "system", "content": system_prompt} + ]) + + writer = get_stream_writer() + writer(FinalResult( + final_report=response.content + )) + + +def make_supervisor_node(): + def parse_response(response_content: str): + import json + """解析模型返回的内容,提取next_step""" + try: + # 尝试直接解析JSON + logging.debug(response_content) + return json.loads(response_content) + except Exception: + # 如果直接解析失败,尝试提取JSON部分 + start = response_content.find('{') + end = response_content.rfind('}') + 1 + if start != -1 and end != -1: + json_str = response_content[start:end] + return json.loads(json_str) + return None + + async def supervisor_node(state: SupervisorState, config) -> Command[Literal[ + GraphNodeType.CLARIFY_NODE, GraphNodeType.PAPER_TEAM, GraphNodeType.CHART_NODE, + GraphNodeType.ANSWER_COMPOSER]]: + rc = parse_research_config(config) + # 1. 定义新成员和描述 + members = [ + GraphNodeType.CLARIFY_NODE.value, + GraphNodeType.PAPER_TEAM.value, + GraphNodeType.CHART_NODE.value, + GraphNodeType.RETRIVAL_TEAM.value, + GraphNodeType.DEEP_RESEARCH_TEAM.value + ] + + members_description = { + GraphNodeType.CLARIFY_NODE: rc.prompt_manager.get_prompt( + name="clarify_node_prompt", + group=rc.prompt_group, + ).format(), + GraphNodeType.PAPER_TEAM: rc.prompt_manager.get_prompt( + name="paper_team_prompt", + group=rc.prompt_group, + ).format(), + GraphNodeType.RETRIVAL_TEAM: rc.prompt_manager.get_prompt( + name="retrieval_team_prompt", + group=rc.prompt_group, + ).format(), + GraphNodeType.CHART_NODE: rc.prompt_manager.get_prompt( + name="report_team_prompt", + group=rc.prompt_group, + ).format(), + GraphNodeType.DEEP_RESEARCH_TEAM: rc.prompt_manager.get_prompt( + name="deep_research_team_prompt", + group=rc.prompt_group, + ).format(), + } + + members_str = "\n".join([f"- {m}" for m in members]) + member_list_string = ', '.join([f'"{node}"' for node in members]) + members_desc_str = "\n".join( + [f"- **{k.value}**: {v.strip()}" for k, v in members_description.items()] + ) + + prompt_template = rc.prompt_manager.get_prompt( + name="supervisor_prompt", + group=rc.prompt_group, + ) + conf_analysis_supervisor_prompt = prompt_template.format( + members=members_str, + members_description=members_desc_str, + member_list=member_list_string + ) + + messages = [ + {"role": "system", "content": conf_analysis_supervisor_prompt}, + ] + state["messages"] + llm = rc.get_model() + response = await llm.ainvoke(messages) + llm_response = response.content + result = parse_response(llm_response) + if not result or result["next"] == END or result["next"] == GraphNodeType.CLARIFY_NODE.value: + return Command( + goto=GraphNodeType.ANSWER_COMPOSER.value, + update={"messages": {"role": "ai", "content": llm_response}} + ) + + return Command( + goto=result["next"] + ) + + return supervisor_node + + +async def question_clarify_node(state: SupervisorState) -> Command[Literal[GraphNodeType.SUMMARIZER]]: + user_reply = interrupt(state["messages"][-1].content) + return Command(goto=GraphNodeType.SUMMARIZER.value, update={ + "messages": HumanMessage( + content=user_reply + ) + }) + + +@progress_stage("论文统计分析") +async def paper_team_node(state: SupervisorState) -> Command[Literal[GraphNodeType.SUMMARIZER]]: + # 调用 Paper Team 的处理流程 + result = await statistics_graph.ainvoke( + {"messages": [("user", state["messages"][-1].content)]}, + {"recursion_limit": 100}, + ) + return Command(goto=GraphNodeType.SUMMARIZER.value, update={ + "messages": HumanMessage( + content=result["messages"][-1].content, name="paper_team" + ) + }) + + +@progress_stage("论文检索") +async def retrival_team_node(state: SupervisorState, config: RunnableConfig) -> Command[Literal[END]]: + rc = parse_research_config(config) + tools = [tavily_search] + if "ragflow" in config["configurable"]: + logging.info("ragflow in config") + knowledge_tool = KnowledgeTool() + tools.append(knowledge_tool.knowledge_retrieve) + # 调用 retrival Team 的处理流程 + system_prompt = """ + 你是一名专精于学术论文检索与数据分析的智能研究助理。 + 你的任务是根据用户请求,**高效查询学术论文、会议论文及相关作者/机构信息**。 + 你应当优先利用 RAG 检索(ragflow)进行信息查找; + 若 RAG 不可用或返回结果为空,则自动使用 Tavily 搜索工具进行查询。 + + --- + + ### 🎯 工作目标 + 1. **优先使用 RAG 检索知识库(ragflow)获取论文、作者、会议信息。** + 2. **当 RAG 检索无结果或无法使用时,自动切换至 Tavily 搜索。** + 3. **每次查询完成后,主动反思结果是否满足用户问题。** + - 如果结果不完整或不相关,请优化查询语句(query)并重试。 + 4. **最多尝试 5 次查询。** + - 若超过 5 次仍未找到有效结果,则返回检索失败的提示(例如:“未能检索到相关论文或信息”)。 + + --- + + ### 🧰 可用工具 + - **ragflow.knowledge_retrieve**:RAG 检索学术知识库内容。 + - **tavily_search**:从互联网检索学术论文、会议及作者信息。 + + --- + + ### 🧠 检索与反思流程 + + 每次检索请执行以下逻辑: + + 1. **执行查询** + * 优先使用 ragflow 进行知识检索; + * 若 RAG 无法使用或结果为空,则改用 tavily_search。 + + 2. **结果评估** + * 检查结果是否满足用户问题; + * 如果不满足,重写查询语句并重试。 + + 3. **重试机制** + * 最多执行 5 次; + * 超过 5 次仍无结果则返回失败信息。 + + --- + + ### 📘 输出要求 + + * 所有内容必须来源于检索结果,不得编造、估算或推断。 + * 回答要简洁、准确,并与用户问题直接相关。 + * 若涉及数据库查询,请仅使用 PythonREPLTool 和 SQLAlchemy。 + * 不进行统计、趋势分析或额外评论,除非用户明确要求。 + + --- + + ### 📄 输出格式规范(新增) + + 每次输出结果时,请严格按照以下格式组织内容: + + #### ✅ 标准输出格式 + + ``` + <在此展示检索得到的论文摘要、会议介绍或作者信息原文,不做改写> + + 【来源】 + + * 来源类型:RAG 检索 / 网络检索(Tavily) + * 来源名称:<数据库名或网站名,如 “IEEE Xplore”, “ACM Digital Library”, “Google Scholar”, “arXiv”, “SpringerLink” 等> + * 检索时间:<自动填入检索执行的时间,如 2025-11-12 14:35> + * 原始链接(若有):<论文或数据源的具体链接> + + ``` + + #### ✅ 多条结果输出格式 + + 若返回多篇论文或多个来源,请以编号形式列出: + ``` + + <论文摘要或核心内容> + + 【来源】 + + * 来源类型:RAG 检索 + * 来源名称:ACM Digital Library + * 检索时间:2025-11-12 14:35 + * 原始链接:[https://dl.acm.org/](https://dl.acm.org/)... + + + * 来源类型:网络检索(Tavily) + * 来源名称:Google Scholar + * 检索时间:2025-11-12 14:36 + * 原始链接:[https://scholar.google.com/](https://scholar.google.com/)... + + ``` + + --- + + ### 🧩 附加说明 + * 若检索失败,请输出: + ``` + + ❌ 未能检索到相关论文或信息,请尝试更换关键词或调整查询范围。 + + ``` + * 若部分结果存在信息缺失,请明确标注“[信息缺失]”。 + + """ + agent = create_agent( + model=rc.get_model(), + tools=tools, # Many tools + middleware=[TodoListMiddleware()], + system_prompt=system_prompt + ) + result = await agent.ainvoke(state, config=config) + return Command( + goto=GraphNodeType.SUMMARIZER.value, + update={ + "messages": [ + HumanMessage(content=result["messages"][-1].content, name="retrival_team") + ] + } + ) + + +@progress_stage("图表生成") +async def chart_node(state: SupervisorState, config: RunnableConfig) -> Command[Literal[GraphNodeType.SUMMARIZER]]: + rc = parse_research_config(config) + llm = rc.get_model() + + chart_tools = [generate_area_chart, generate_bar_chart, generate_column_chart, generate_pie_chart, + generate_scatter_chart, generate_line_chart, generate_radar_chart, generate_wordcloud] + system_prompt = rc.prompt_manager.get_prompt( + name="report_chart_agent_sys_prompt", + group=rc.prompt_group, + ).format() + agent = create_agent( + model=llm, + tools=chart_tools, + system_prompt=system_prompt + ) + + result = await agent.ainvoke( + { + "user_input": state["messages"][-1].content, + "messages": [ + {"role": "human", "content": state["messages"][-1].content} + ], + "charts": [], + "report": "", + }) + + return Command(goto=GraphNodeType.SUMMARIZER.value, update={ + "messages": HumanMessage( + content=result["messages"][-1].content, name="report_team" + ) + }) + + +@progress_stage("顶会深度研究") +async def deep_research_team_node(state: SupervisorState, config: RunnableConfig) -> Command[Literal[END]]: + # 调用 retrival Team 的处理流程 + if os.getenv("TAVILY_API_KEYS"): + selected_key, all_keys_usage = select_api_key() + if selected_key is None: + logging.error("no tavily key can be used, please set first.") + for key, usage in all_keys_usage.items(): + logging.error(f"API Key: {key} - Plan Limit: {usage['plan_limit']}, Plan Usage: {usage['plan_usage']}") + writer = get_stream_writer() + writer({"result": "no tavily key can be used, please set first."}) + return Command(goto=END) + + parent_configurable = config.get("configurable", {}) + deep_research_config = { + **parent_configurable, + "prompt_group": SceneType.DEEP_RESEARCH.value, + "allow_user_clarification": False, + "allow_edit_research_brief": False, + "allow_edit_report_outline": False, + "allow_publish_result": False, + } + + result = await conference_research_graph.with_config(configurable=deep_research_config).ainvoke( + {"messages": [("user", state["messages"][-1].content)]} + ) + writer = get_stream_writer() + writer({"result": result["messages"][-1].content}) + return Command(goto=END, update={ + "messages": HumanMessage( + content=result["messages"][-1].content, name="deep_research_team" + ) + }) + + +# 构建图 +builder = StateGraph(SupervisorState) +builder.add_node(GraphNodeType.SUMMARIZER.value, summarization_node) +builder.add_node(GraphNodeType.SUPERVISOR.value, make_supervisor_node()) +builder.add_node(GraphNodeType.CLARIFY_NODE.value, question_clarify_node) +builder.add_node(GraphNodeType.PAPER_TEAM.value, paper_team_node) +builder.add_node(GraphNodeType.CHART_NODE.value, chart_node) +builder.add_node(GraphNodeType.RETRIVAL_TEAM.value, retrival_team_node) +builder.add_node(GraphNodeType.DEEP_RESEARCH_TEAM.value, deep_research_team_node) +builder.add_node(GraphNodeType.ANSWER_COMPOSER.value, answer_composer_node) + + +builder.set_entry_point(GraphNodeType.SUMMARIZER.value) +builder.add_edge(GraphNodeType.SUMMARIZER.value, GraphNodeType.SUPERVISOR.value) +builder.add_edge(GraphNodeType.ANSWER_COMPOSER.value, END) +checkpointer = InMemorySaver() +graph = builder.compile(checkpointer=checkpointer) diff --git a/deepinsight/core/agent/conference_research/conf_stat_value_mining.py b/deepinsight/core/agent/conference_research/conf_stat_value_mining.py index 590677655acaa13ccca212a3d4b4acdc6ef91e2d..cf24b00310cd5386d80ac083c0db1bee35660a06 100644 --- a/deepinsight/core/agent/conference_research/conf_stat_value_mining.py +++ b/deepinsight/core/agent/conference_research/conf_stat_value_mining.py @@ -21,6 +21,7 @@ from deepinsight.core.utils.progress_utils import progress_stage from deepinsight.core.utils.research_utils import parse_research_config from deepinsight.core.tools.tavily_search import tavily_search from deepinsight.core.utils.context_utils import DefaultSummarizationMiddleware +from deepinsight.utils.db_schema_utils import get_db_models_source_markdown from integrations.mcps.generate_chart import generate_column_chart, generate_bar_chart, generate_pie_chart @@ -44,10 +45,12 @@ async def get_deep_agents(config: RunnableConfig, prompt_template_name, extent_t llm_model = rc.get_model() prompt_manager = rc.prompt_manager prompt_group: str = rc.prompt_group + vars_to_format = dict(prompt_vars or {}) + vars_to_format.setdefault("db_models_description", get_db_models_source_markdown()) system_prompt = prompt_manager.get_prompt( name=prompt_template_name, group=prompt_group, - ).format(**prompt_vars) + ).format(**vars_to_format) tools = [PythonREPLTool(), tavily_search, generate_wordcloud, generate_column_chart, generate_bar_chart, generate_pie_chart] if extent_tools: @@ -132,7 +135,6 @@ async def tech_topics_node(state: ConferenceStaticState, config: RunnableConfig) if not mem_file_system_instance.exists(output_file): logging.error(f"get tech_topics failed, origin question:{state['origin_question']}") return {"tech_topics": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return {"tech_topics": mem_file_system_instance.read(output_file)} @@ -159,7 +161,6 @@ async def research_hotspots_node(state: ConferenceStaticState, config: RunnableC if not mem_file_system_instance.exists(output_file): logging.error(f"get research_hotspots failed, origin question:{state['origin_question']}") return {"research_hotspots": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return { "research_hotspots": mem_file_system_instance.read(output_file) } @@ -189,7 +190,6 @@ async def national_tech_profile_node(state: ConferenceStaticState, config: Runna if not mem_file_system_instance.exists(output_file): logging.error(f"get national_tech_profile failed, origin question:{state['origin_question']}") return {"national_tech_profile": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return { "national_tech_profile": mem_file_system_instance.read(output_file) } @@ -220,7 +220,6 @@ async def institution_overview_node(state: ConferenceStaticState, config: Runnab if not mem_file_system_instance.exists(output_file): logging.error(f"get institution_overview failed, origin question:{state['origin_question']}") return {"institution_overview": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return { "institution_overview": mem_file_system_instance.read(output_file) } @@ -249,7 +248,6 @@ async def inter_institution_collab_node(state: ConferenceStaticState, config: Ru if not mem_file_system_instance.exists(output_file): logging.error(f"get inter_institution_collab failed, origin question:{state['origin_question']}") return {"inter_institution_collab": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return { "inter_institution_collab": mem_file_system_instance.read(output_file) } @@ -278,7 +276,6 @@ async def high_potential_tech_transfer_node(state: ConferenceStaticState, config if not mem_file_system_instance.exists(output_file): logging.error(f"get high_potential_tech failed, origin question:{state['origin_question']}") return {"high_potential_tech_transfer": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return { "high_potential_tech_transfer": mem_file_system_instance.read(output_file) } @@ -306,7 +303,6 @@ async def academic_leaders_node(state: ConferenceStaticState, config: RunnableCo if not mem_file_system_instance.exists(output_file): logging.error(f"get academic_leaders failed, origin question:{state['origin_question']}") return {"high_potential_tech_transfer": ""} - mem_file_system_instance.sync_with_real_fs(real_dir="./", import_file=output_file) return { "high_potential_tech_transfer": mem_file_system_instance.read(output_file) } diff --git a/deepinsight/core/agent/conference_research/ppt_generate.py b/deepinsight/core/agent/conference_research/ppt_generate.py index cfb5311bfce910f9493a475f249ba31e480cf7e9..7b3a87a7dadfd8f0d28c7458dac30b237cefd7fc 100644 --- a/deepinsight/core/agent/conference_research/ppt_generate.py +++ b/deepinsight/core/agent/conference_research/ppt_generate.py @@ -5,7 +5,7 @@ import json import logging import os from pathlib import PurePosixPath -from typing import Annotated, Any, Dict, List, Literal, Optional, TypedDict, Union, get_args, get_origin +from typing import Annotated, Any, Dict, List, Literal, Optional, TypedDict, Union, get_args, get_origin, Callable, Type from pydantic import BaseModel, Field from langchain_core.messages import BaseMessage, HumanMessage, AIMessage @@ -106,13 +106,21 @@ class PPTGraphNodeType(str, Enum): SAVE_PPT_JSON = "save_ppt_json" GENERATE_OVERVIEW_PAGE = "generate_overview_page" - GENERATE_SUBMISSION_PAGE = "generate_submission_page" GENERATE_KEYNOTES_PAGE = "generate_keynotes_page" GENERATE_TOPIC_CONTENT_PAGE = "generate_topic_content_page" GENERATE_TOPIC_DETAILS_PAGE = "generate_topic_details_page" GENERATE_BEST_PAPERS_PAGE = "generate_best_papers_page" GENERATE_SUMMARY_PAGE = "generate_summary_page" + GENERATE_TECH_THEME_PAGE = "generate_tech_theme_page" + GENERATE_RESEARCH_HOTSPOT_COLLAB_01_PAGE = "generate_research_hotspot_collab_01_page" + GENERATE_RESEARCH_HOTSPOT_COLLAB_02_PAGE = "generate_research_hotspot_collab_02_page" + GENERATE_COUNTRY_TECH_FEATURE_PAGE = "generate_country_tech_feature_page" + GENERATE_INSTITUTION_TECH_FEATURE_PAGE = "generate_institution_tech_feature_page" + GENERATE_INSTITUTION_TECH_STRENGTH_PAGE = "generate_institution_tech_strength_page" + GENERATE_INSTITUTION_COOPERATION_PAGE = "generate_institution_cooperation_page" + GENERATE_HIGH_POTENTIAL_TECH_TRANSFER_PAGE = "generate_high_potential_tech_transfer_page" + def __str__(self): return self.value @@ -123,13 +131,21 @@ class PPTState(TypedDict): ppt_json: Optional[List[Dict[str, Any]]] sections: Optional[Dict[str, Any]] overview_json: Optional[Any] - submission_json: Optional[List[Any]] keynote_json: Optional[Any] topic_content_json: Optional[Any] topic_details_json: Optional[List[Any]] best_papers_json: Optional[List[Any]] summary_json: Optional[Any] + tech_theme_page_json: Optional[Any] + research_hotspot_collab_01_page_json: Optional[Any] + research_hotspot_collab_02_page_json: Optional[Any] + country_tech_feature_page_json: Optional[Any] + institution_tech_feature_page_json: Optional[Any] + institution_tech_strength_page_json: Optional[Any] + institution_cooperation_page_json: Optional[Any] + high_potential_tech_transfer_page_json: Optional[Any] + # ========== 通用结构 ========== class ImageContent(BaseModel): @@ -153,6 +169,106 @@ class BasePage(BaseModel): # ========== 各类型页面定义 ========== +class TechThemePageContent(BaseModel): + tech_field_png: Optional[ImageContent] = Field(None, description="技术主题分析图") + key_tech_intro: Optional[str] = Field(None, description="技术主题分析介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息:1.主题概览、2.趋势分析、3.主题展望,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行;") + key_tech_summary: Optional[str] = Field(None, description="一段话,技术主题总结与洞察,长度100-200,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class TechThemePage(BasePage): + type: str = "tech_theme_page" + content: TechThemePageContent + + +# =============== C. 研究热点与跨区域技术合作(01) =============== +class ResearchHotspotCollab01PageContent(BaseModel): + keyword_cloud_png: Optional[ImageContent] = Field(None, description="关键词云图") + keyword_intro: Optional[str] = Field(None, description="关键词与研究热点介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息:1. 关键词分布概述、2. 关键词趋势分析、3. 技术领域融合分布概述、4. 技术领域融合分析,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行") + keyword_couple_analysis_png: Optional[ImageContent] = Field(None, + description="关键词耦合分析图") + keyword_summary: Optional[str] = Field(None, description="一段话,仅对关键词相关内容进行总结,不需要涉及和主题相关内容,长度100字以内,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class ResearchHotspotCollab01Page(BasePage): + type: str = "research_hotspot_collab_01_page" + content: ResearchHotspotCollab01PageContent + + +# =============== D. 研究热点与跨区域技术合作(02) =============== +class ResearchHotspotCollab02PageContent(BaseModel): + keyword_topic_csv: Optional[TableContent] = Field(None, description="关键词主题分析表格") + keyword_topic_intro: Optional[str] = Field(None, description="关键词主题分布介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息,标题加粗:1.概述、2.技术趋势,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行") + keyword_topic_summary: Optional[str] = Field(None, description="一段话,关键词主题总结与趋势洞察,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,长度100字左右;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class ResearchHotspotCollab02Page(BasePage): + type: str = "research_hotspot_collab_02_page" + content: ResearchHotspotCollab02PageContent + + +# =============== E. 国家/地区技术特征分析 =============== +class CountryTechFeaturePageContent(BaseModel): + country_tech_top_png: Optional[ImageContent] = Field(None, + description="国家/地区技术热度图") + country_tech_strength_csv: Optional[TableContent] = Field(None, + description="国家/地区技术强度表格,通常国家地区众多,因此精简原始数据,每个国家或地区选取占比最高的两条记录,如美国只可以出现两行,中国只可出现两行,例如 国家/地区,技术优势领域,占比\n美国,大数据与机器学习系统,34.2%\n美国,操作系统,14.4%\n中国,大数据与机器学习系统,45.5%\n中国,文件与存储系统,16.2% ...") + country_tech_intro: Optional[str] = Field(None, description="国家/地区技术特征介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息,标题加粗:1.概述、2.中国技术特征,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行;内容要求禁止空泛的总结句,直接、具体地切入主题") + country_tech_summary: Optional[str] = Field(None, description="一段话,国家/地区技术特征总结,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,长度100字左右;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class CountryTechFeaturePage(BasePage): + type: str = "country_tech_feature_page" + content: CountryTechFeaturePageContent + + +# =============== F. 机构技术特征分析 =============== +class InstitutionTechFeaturePageContent(BaseModel): + top_institution_png: Optional[ImageContent] = Field(None, + description="领先机构分布图") + institution_tech_feat_intro: Optional[str] = Field(None, description="机构技术特征介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息:1.概述、2.机构研究重点、3.产学研分析、4.中国机构概述,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行;内容要求禁止空泛的总结句,直接、具体地切入主题;涉及到 企业 高校 字样用红色标记") + compony_school_analysis_png: Optional[ImageContent] = Field(None, + description="企业与高校分布分析图") + institution_tech_feat_summary: Optional[str] = Field(None, description="一段话,总结机构技术特征,长度100字左右,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + + +class InstitutionTechFeaturePage(BasePage): + type: str = "institution_tech_feature_page" + content: InstitutionTechFeaturePageContent + + +# =============== G. 机构技术优势分析 =============== +class InstitutionTechStrengthPageContent(BaseModel): + university_tech_strength_csv: Optional[TableContent] = Field(None, + description="高校技术强度表格,通常高校众多,因此精简原始数据,每个高校选取占比最高的一条记录即可,总行数最多不要超过8条") + compony_tech_strength_csv: Optional[TableContent] = Field(None, + description="企业技术强度表格,通常企业众多,因此精简原始数据,每个企业选取占比最高的两条记录即可") + institution_tech_strength_intro: Optional[str] = Field(None, description="机构技术优势介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息:1.高校技术优势分析及趋势、2.企业技术优势分析及趋势总结、3.启示,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行;内容要求禁止空泛的总结句,直接、具体地切入主题") + institution_tech_strength_summary: Optional[str] = Field(None, description="一段话,机构技术优势总结,长度100-200,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class InstitutionTechStrengthPage(BasePage): + type: str = "institution_tech_strength_page" + content: InstitutionTechStrengthPageContent + + +# =============== H. 跨机构合作网络分析 =============== +class InstitutionCooperationPageContent(BaseModel): + institution_cooperation_png: Optional[ImageContent] = Field(None, + description="跨机构合作网络图") + institution_cooperation_intro: Optional[str] = Field(None, description="跨机构合作网络介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息:1.合作网络概述、2.TOP3合作网络、3.企业合作网络、4.华为合作网络,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行;内容要求禁止空泛的总结句,直接、具体地切入主题") + institution_cooperation_summary: Optional[str] = Field(None, description="一段话,跨机构合作网络总结与洞察,长度100-200,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class InstitutionCooperationPage(BasePage): + type: str = "institution_cooperation_page" + content: InstitutionCooperationPageContent + + +# =============== I. 高潜技术转化分析 =============== +class HighPotentialTechTransferPageContent(BaseModel): + high_potential_csv: Optional[TableContent] = Field(None, + description="高潜技术转化相关数据表格") + high_potential_intro: Optional[str] = Field(None, description="高潜技术转化分析介绍,长度100-200,请从原文中的以下内容获取对应信息,并且精简对应内容(事实描述用一句话略写)并保留全部主要信息:1.概述、2.Top3高潜技术、3.业务启示,对于每部分内容用段落形式,标题需带有序号且加粗,内容中关键信息用红色标记,换行时不要空行;内容要求禁止空泛的总结句,直接、具体地切入主题") + high_potential_summary: Optional[str] = Field(None, description="一段话,高潜技术转化总结与趋势洞察,长度100-200,如果原文中有一句话总结 内容,则直接借鉴原文,但内容要求禁止空泛的总结句,直接、具体地切入主题,如第一句不以“基于”开头,;关键内容使用黄色标记,但不要整个字段内容都是黄色的") + +class HighPotentialTechTransferPage(BasePage): + type: str = "high_potential_tech_transfer_page" + content: HighPotentialTechTransferPageContent + class CoverPageContent(BaseModel): conference_name: Optional[str] = Field(None, description="会议名称") @@ -172,7 +288,7 @@ class ContentPage(BasePage): # --- Conference Overview --- class ConfOverviewPageContent(BaseModel): conf_name: Optional[str] = Field(None, description="会议名称,长度10以内") - conf_info: Optional[str] = Field(None, description="会议基本信息概述,长度200-300,"+CONFERENCE_OVERVIEW_EXAMPLE) + conf_info: Optional[str] = Field(None, description="会议基本信息概述,长度200-300," + CONFERENCE_OVERVIEW_EXAMPLE) organizer_level: Optional[str] = Field(None, description="会议级别,长度128以内") conf_topics: Optional[str] = Field(None, description="会议主题介绍,长度128以内") conf_loc: Optional[str] = Field(None, description="会议地点,长度50以内") @@ -182,7 +298,10 @@ class ConfOverviewPageContent(BaseModel): conf_committee: Optional[str] = Field(None, description="会议委员会,长度80以内") conf_institution: Optional[str] = Field(None, description="会议主要机构,长度80以内") submit_papers: Optional[str] = Field(None, description="会议投稿情况概述,长度80以内") - total_trend: Optional[str] = Field(None, description="会议论文总体趋势分析描述,使用markdown列表写法列出多条,长度300-400"+DEFAULT_LIST_STYLE_DESC) + total_trend: Optional[str] = Field( + None, + description="会议论文总体趋势分析描述,使用markdown列表写法列出多条,长度300-400" + DEFAULT_LIST_STYLE_DESC + ) class ConfOverviewPage(BasePage): @@ -239,7 +358,8 @@ class FirstAuthorPage(BasePage): # --- Coauthor --- class CoauthorPageContent(BaseModel): - coauthor_statistic_csv: Optional[TableContent] = Field(None, description="合作作者统计表格内容") + coauthor_statistic_csv: Optional[TableContent] = Field(None, + description="合作作者统计表格内容") class CoauthorPage(BasePage): @@ -247,26 +367,73 @@ class CoauthorPage(BasePage): content: CoauthorPageContent -# --- Submission Page --- -class SubmissionPageContent(BaseModel): - research_fields: Optional[ResearchFieldsPageContent] = Field(None, description="论文主题领域分布") - country_analysis: Optional[CountryAnalysisPageContent] = Field(None, description="论文投稿国家/地区分布分析") - institution_analysis: Optional[InstitutionAnalysisPageContent] = Field(None, description="论文投稿机构分布分析") - first_author: Optional[FirstAuthorPageContent] = Field(None, description="论文第一作者分析") - coauthor: Optional[CoauthorPageContent] = Field(None, description="论文合作作者分析") - - # --- Keynote Page --- class KeynotePageContent(BaseModel): - keynote_title: Optional[str] = Field(None, description="主旨演讲标题") - speaker: Optional[str] = Field(None, description="主旨演讲者,长度50-100") - keynote_abstract: Optional[str] = Field(None, description="主旨演讲摘要,长度150-300") - keynote_background: Optional[str] = Field(None, description="主旨演讲why,长度150-300,优先从源数据对应的why里面获取") - keynote_objective: Optional[str] = Field(None, description="主旨演讲what,长度150-300,优先从源数据对应的what里面获取") - keynote_method: Optional[str] = Field(None, description="主旨演讲how,长度150-300,优先从源数据对应的how里面获取") - keynote_inspiration: Optional[str] = Field(None, description="主旨演讲对业务启示,长度150-300") - keynote_summary: Optional[str] = Field(None, description="主旨演讲总结,长度128-240") - keynote_picture: Optional[ImageContent] = Field(None, description="演讲嘉宾照片,如果源数据里面有,则必须填写") + keynote_title: Optional[str] = Field( + None, + description="主旨演讲标题,需准确概括演讲核心主题,体现前瞻性和专业性" + ) + + speaker: Optional[str] = Field( + None, + description="""演讲嘉宾信息(长度50-100字)。 +核心要点:突出嘉宾与主题的关联性及权威性; +撰写特点:简洁凝练,聚焦“身份标签+核心成就”,优先选择与主题直接相关的经历; +示例方向:XX大学计算机科学系教授,ACM Fellow,长期深耕人工智能生成式模型领域,主导开发了XX模型。""" + ) + + keynote_abstract: Optional[str] = Field( + None, + description="""主要内容和思想(长度100-200字)。 +核心要点:梳理逻辑框架与核心观点,提炼最具价值的思想主张; +撰写特点:结构化呈现(背景铺垫-核心观点-论据支撑),突出创新性和前瞻性,标注打破传统认知的关键思想; +示例方向:首先分析XX技术的“效率瓶颈”与“伦理争议”,接着提出“XX融合架构”解决方案,最后强调“技术向善”思想。""" + ) + + keynote_background: Optional[str] = Field( + None, + description="""主旨演讲why(为何重要/为何关注,长度100-200字,优先从源数据对应的why获取)。 +核心要点:阐明时代背景、行业痛点或战略意义,回答“为何值得关注”; +撰写特点:结合宏观趋势与实际需求,从行业价值、技术突破等角度切入,用数据/案例增强说服力; +示例方向:全球XX市场年复合增长率达XX%,但面临“落地成本高”痛点,该演讲方案可降低成本XX%,为行业规避风险提供参考。""" + ) + + keynote_objective: Optional[str] = Field( + None, + description="""主旨演讲what(核心是什么/解决什么问题,长度100-200字,优先从源数据对应的what获取)。 +核心要点:明确聚焦的核心问题及提出的核心概念、方案; +撰写特点:精准聚焦,用“问题-答案”逻辑呈现,可对比传统做法与新方式凸显差异; +示例方向:核心问题是“如何在保证XX模型精度的前提下降低算力依赖”,提出“轻量化蒸馏+动态剪枝”策略,实现精度损失 str: return json.dumps(template, indent=2, ensure_ascii=False) +def make_generate_page( + page_content_cls: Type[BaseModel], + page_model_cls: Type[BaseModel], + md_filename: str, + return_key: str, + prompt_name: str = "default", + tools: list = None, +) -> Callable[[object, object], object]: + """ + 生成一个异步 page 生成函数的闭包。 + 参数: + - page_content_cls: Pydantic 模型类,用于生成 response_format(如 TechThemePageContent) + - page_model_cls: 返回的 page wrapper 类(如 TechThemePage) + - md_filename: 在 state['sections'] 中查找的 md 文件名(字符串) + - return_key: 返回字典中的 key 名称(例如 'tech_theme_page_json') + - prompt_name: 使用的 prompt 模板名称(默认 'default') + - tools: 可选的工具列表(默认 [download_file_from_url] 在调用处传入或 None) + + 返回: + - 一个 async 函数 (state: PPTState, config: RunnableConfig) -> dict | None + """ + if tools is None: + tools = [download_file_from_url] + + async def _generate(state: "PPTState", config: "RunnableConfig"): + md_content = state["sections"].get(md_filename, "") + if not md_content: + logging.warning(f"Source markdown {md_filename!r} is empty for {return_key}") + return + + rc = parse_research_config(config) + prompt = rc.prompt_manager.get_prompt( + name=prompt_name, + group=rc.prompt_group + ).format( + response_format=generate_json_template(page_content_cls), + ) + + llm = rc.get_model() + agent = create_agent( + model=llm, + system_prompt=prompt, + tools=tools, + response_format=page_content_cls + ) + + try: + response = await agent.with_retry().ainvoke( + input=dict(messages=[HumanMessage(content=md_content)]) + ) + except Exception as e: + logging.exception(f"Error invoking agent for {return_key}: {e}") + return + + structured_response = response.get("structured_response") + if not structured_response: + logging.warning(f"LLM generated empty structured_response for {return_key}") + return + + # page_model_cls 期望形如 TechThemePage(content=structured_response) + try: + page_obj = page_model_cls(content=structured_response) + except Exception as e: + # 兼容一些 Page 类可能需要额外字段的情况 + logging.exception(f"Failed to construct page model for {return_key}: {e}") + return + + return {return_key: page_obj} + + return _generate + + async def generate_overview_page(state: PPTState, config: RunnableConfig): md_content = state["sections"].get("conference_overview.md", "") if not md_content: @@ -536,60 +895,13 @@ async def generate_overview_page(state: PPTState, config: RunnableConfig): ) - -async def generate_submission_page(state: PPTState, config: RunnableConfig): - md_content = state["sections"].get("conference_submission.md", "") - if not md_content: - logging.warning(f"Submission page is empty") - return - rc = parse_research_config(config) - prompt = rc.prompt_manager.get_prompt("default", rc.prompt_group).format( - response_format=generate_json_template(SubmissionPageContent), - ) - llm = rc.default_model - agent = create_agent( - model=llm, - system_prompt=prompt, - tools=[download_file_from_url], - response_format=ToolStrategy(SubmissionPageContent) - ) - response = await agent.with_retry().ainvoke( - input=dict( - messages=[HumanMessage(content=state["sections"].get("conference_submission.md", ""))] - ) - ) - structured_response: Optional[SubmissionPageContent] = response.get("structured_response") - if not structured_response: - logging.warning(f"LLM generate submission response is empty") - return - return dict( - submission_json=[ - ResearchFieldsPage( - content=structured_response.research_fields - ), - CountryAnalysisPage( - content=structured_response.country_analysis - ), - InstitutionAnalysisPage( - content=structured_response.institution_analysis - ), - FirstAuthorPage( - content=structured_response.first_author - ), - CoauthorPage( - content=structured_response.coauthor - ), - ] - ) - - async def generate_keynotes_page(state: PPTState, config: RunnableConfig): md_content = state["sections"].get("conference_keynotes.md", "") if not md_content: logging.warning(f"Keynote page is empty") return rc = parse_research_config(config) - prompt = rc.prompt_manager.get_prompt("default", rc.prompt_group).format( + prompt = rc.prompt_manager.get_prompt("keynote", rc.prompt_group).format( response_format=generate_json_template(KeynotePageContentList), ) llm = rc.default_model @@ -655,7 +967,7 @@ async def generate_topic_details_page(state: PPTState, config: RunnableConfig): logging.warning(f"Topic details page is empty") return rc = parse_research_config(config) - prompt = rc.prompt_manager.get_prompt("default", rc.prompt_group).format( + prompt = rc.prompt_manager.get_prompt("topic_detail", rc.prompt_group).format( response_format=generate_json_template(TopicDetailPageContentList), ) llm = rc.default_model @@ -758,9 +1070,10 @@ async def generate_summary_page(state: PPTState, config: RunnableConfig): async def assemble_ppt_json(state: PPTState, config: RunnableConfig): - if state["ppt_json"] is not None: + if state.get("ppt_json") is not None: return state - # 先 cover_page + + # ------------------ cover page ------------------ cover = { "type": "cover_page", "content": { @@ -779,37 +1092,59 @@ async def assemble_ppt_json(state: PPTState, config: RunnableConfig): cover["content"]["date"] = formatted_date pages: List[Dict[str, Any]] = [] pages.append(cover) - # skip_fill page + + # ------------------ skip_fill page ------------------ pages.append({"type": "content_page", "skip_fill": True}) - # 接着 overview页面 - if state.get("overview_json") is not None: - pages.append(state["overview_json"].model_dump(by_alias=True)) - # submission / total analysis - if state.get("submission_json") is not None: - for each in state["submission_json"]: - pages.append(each.model_dump(by_alias=True)) - # keynotes + + # ------------------ overview page ------------------ + if ov is not None: + pages.append(ov.model_dump(by_alias=True)) + + + # ------------------ 新增 8 个页面 ------------------ + new_pages_keys = [ + "tech_theme_page_json", + "research_hotspot_collab_01_page_json", + "research_hotspot_collab_02_page_json", + "country_tech_feature_page_json", + "institution_tech_feature_page_json", + "institution_tech_strength_page_json", + "institution_cooperation_page_json", + "high_potential_tech_transfer_page_json", + ] + + for key in new_pages_keys: + page_obj = state.get(key) + if page_obj is not None: + pages.append(page_obj.model_dump(by_alias=True)) + + # ------------------ keynotes ------------------ if state.get("keynote_json") is not None: for each in state["keynote_json"]: pages.append(each.model_dump(by_alias=True)) - # topic content + + # ------------------ topic content ------------------ if state.get("topic_content_json") is not None: pages.append(state["topic_content_json"].model_dump(by_alias=True)) - # topic details + + # ------------------ topic details ------------------ for t in state.get("topic_details_json", []): pages.append(t.model_dump(by_alias=True)) - # best papers + + # ------------------ best papers ------------------ for bp in state.get("best_papers_json", []): pages.append(bp.model_dump(by_alias=True)) - # summary page + + # ------------------ summary page ------------------ if state.get("summary_json") is not None: pages.append(state["summary_json"].model_dump(by_alias=True)) - # 将图片路径进行归一化,保证 PPT 模板服务可直接读取 - rc = parse_research_config(config) - _normalize_image_paths_in_pages(pages, rc) + # ------------------ 保存 ppt_json ------------------ state["ppt_json"] = pages + # 将图片路径进行归一化,保证 PPT 模板服务可直接读取 + rc = parse_research_config(config) + _normalize_image_paths_in_pages(pages, rc) time_for_filename = now.strftime("%Y%m%d%H%M%S") current_thread_work_root = os.path.join(rc.work_root, "conference_report_result", rc.thread_id) @@ -839,7 +1174,6 @@ builder = StateGraph(PPTState) builder.add_node(PPTGraphNodeType.CHECK_EXISTING_PPT, check_existing_ppt) builder.add_node(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, load_conference_sections) builder.add_node(PPTGraphNodeType.GENERATE_OVERVIEW_PAGE, generate_overview_page) -builder.add_node(PPTGraphNodeType.GENERATE_SUBMISSION_PAGE, generate_submission_page) builder.add_node(PPTGraphNodeType.GENERATE_KEYNOTES_PAGE, generate_keynotes_page) builder.add_node(PPTGraphNodeType.GENERATE_TOPIC_CONTENT_PAGE, generate_topic_content_page) builder.add_node(PPTGraphNodeType.GENERATE_TOPIC_DETAILS_PAGE, generate_topic_details_page) @@ -847,6 +1181,86 @@ builder.add_node(PPTGraphNodeType.GENERATE_BEST_PAPERS_PAGE, generate_best_paper builder.add_node(PPTGraphNodeType.GENERATE_SUMMARY_PAGE, generate_summary_page) builder.add_node(PPTGraphNodeType.ASSEMBLE_PPT_JSON, assemble_ppt_json) builder.add_node(PPTGraphNodeType.SAVE_PPT_JSON, save_ppt_json) +builder.add_node( + PPTGraphNodeType.GENERATE_TECH_THEME_PAGE, + make_generate_page( + TechThemePageContent, + TechThemePage, + md_filename="tech_topics.md", + return_key="tech_theme_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_RESEARCH_HOTSPOT_COLLAB_01_PAGE, + make_generate_page( + ResearchHotspotCollab01PageContent, + ResearchHotspotCollab01Page, + md_filename="research_hotspots.md", + return_key="research_hotspot_collab_01_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_RESEARCH_HOTSPOT_COLLAB_02_PAGE, + make_generate_page( + ResearchHotspotCollab02PageContent, + ResearchHotspotCollab02Page, + md_filename="research_hotspots.md", + return_key="research_hotspot_collab_02_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_COUNTRY_TECH_FEATURE_PAGE, + make_generate_page( + CountryTechFeaturePageContent, + CountryTechFeaturePage, + md_filename="national_tech_profile.md", + return_key="country_tech_feature_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_INSTITUTION_TECH_FEATURE_PAGE, + make_generate_page( + InstitutionTechFeaturePageContent, + InstitutionTechFeaturePage, + md_filename="institution_overview.md", + return_key="institution_tech_feature_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_INSTITUTION_TECH_STRENGTH_PAGE, + make_generate_page( + InstitutionTechStrengthPageContent, + InstitutionTechStrengthPage, + md_filename="institution_overview.md", + return_key="institution_tech_strength_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_INSTITUTION_COOPERATION_PAGE, + make_generate_page( + InstitutionCooperationPageContent, + InstitutionCooperationPage, + md_filename="inter_institution_collab.md", + return_key="institution_cooperation_page_json" + ) +) + +builder.add_node( + PPTGraphNodeType.GENERATE_HIGH_POTENTIAL_TECH_TRANSFER_PAGE, + make_generate_page( + HighPotentialTechTransferPageContent, + HighPotentialTechTransferPage, + md_filename="high_potential_tech_transfer.md", + return_key="high_potential_tech_transfer_page_json" + ) +) + # 添加边 builder.set_entry_point(PPTGraphNodeType.CHECK_EXISTING_PPT) @@ -862,20 +1276,36 @@ def after_check_exsiting_ppt(state: PPTState, config: RunnableConfig): builder.add_conditional_edges(PPTGraphNodeType.CHECK_EXISTING_PPT, after_check_exsiting_ppt) builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_OVERVIEW_PAGE) -builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_SUBMISSION_PAGE) builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_KEYNOTES_PAGE) builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_TOPIC_CONTENT_PAGE) builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_TOPIC_DETAILS_PAGE) builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_BEST_PAPERS_PAGE) builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_SUMMARY_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_TECH_THEME_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_RESEARCH_HOTSPOT_COLLAB_01_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_RESEARCH_HOTSPOT_COLLAB_02_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_COUNTRY_TECH_FEATURE_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_INSTITUTION_TECH_FEATURE_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_INSTITUTION_TECH_STRENGTH_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_INSTITUTION_COOPERATION_PAGE) +builder.add_edge(PPTGraphNodeType.LOAD_CONFERENCE_SECTIONS, PPTGraphNodeType.GENERATE_HIGH_POTENTIAL_TECH_TRANSFER_PAGE) + builder.add_edge(PPTGraphNodeType.GENERATE_OVERVIEW_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) -builder.add_edge(PPTGraphNodeType.GENERATE_SUBMISSION_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) builder.add_edge(PPTGraphNodeType.GENERATE_KEYNOTES_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) builder.add_edge(PPTGraphNodeType.GENERATE_TOPIC_CONTENT_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) builder.add_edge(PPTGraphNodeType.GENERATE_TOPIC_DETAILS_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) builder.add_edge(PPTGraphNodeType.GENERATE_BEST_PAPERS_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) builder.add_edge(PPTGraphNodeType.GENERATE_SUMMARY_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_TECH_THEME_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_RESEARCH_HOTSPOT_COLLAB_01_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_RESEARCH_HOTSPOT_COLLAB_02_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_COUNTRY_TECH_FEATURE_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_INSTITUTION_TECH_FEATURE_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_INSTITUTION_TECH_STRENGTH_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_INSTITUTION_COOPERATION_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) +builder.add_edge(PPTGraphNodeType.GENERATE_HIGH_POTENTIAL_TECH_TRANSFER_PAGE, PPTGraphNodeType.ASSEMBLE_PPT_JSON) + builder.add_edge(PPTGraphNodeType.ASSEMBLE_PPT_JSON, PPTGraphNodeType.SAVE_PPT_JSON) builder.add_edge(PPTGraphNodeType.SAVE_PPT_JSON, END) diff --git a/deepinsight/core/agent/conference_research/supervisor.py b/deepinsight/core/agent/conference_research/supervisor.py index 87728ea9f1660d05b8c3a86717f85795333b3f1b..2961d29b6cab55f93580997ef8b5d2398687bf9c 100644 --- a/deepinsight/core/agent/conference_research/supervisor.py +++ b/deepinsight/core/agent/conference_research/supervisor.py @@ -134,7 +134,13 @@ async def question_clarify_node(state: ConferenceState, config: RunnableConfig): ) else: return Command( - goto=result.particapant_members, + goto=[ + ConferenceGraphNodeType.CONFERENCE_OVERVIEW, + ConferenceGraphNodeType.CONFERENCE_SUBMISSION, + ConferenceGraphNodeType.CONFERENCE_KEYNOTE, + ConferenceGraphNodeType.CONFERENCE_TOPIC, + ConferenceGraphNodeType.CONFERENCE_BEST_PAPER, + ], ) @@ -248,8 +254,8 @@ async def insight_summary_node(state: ConferenceState, config: RunnableConfig): ).format() output_file = f"/{str(rc.run_id)}/conference_summary.md" logging.debug( - f"conference_best_papers_summary:{state['conference_best_papers_summary']}, conference_topic:{state.get('conference_topic', '')}") - user_prompt = f"学术会议价值论文列表:{state['conference_best_papers_summary']},会议主题相关信息:{state.get('conference_topic', '')},保存到路径:{output_file} " + f"conference_best_papers_summary:{state.get('conference_best_papers_summary', '')}, conference_topic:{state.get('conference_topic', '')}") + user_prompt = f"学术会议价值论文列表:{state.get('conference_best_papers_summary', '')},会议主题相关信息:{state.get('conference_topic', '')},保存到路径:{output_file} " tools = register_fs_tools(fs_instance) tool_instance = TavilySearch( max_results=2, @@ -295,12 +301,12 @@ async def insight_summary_node(state: ConferenceState, config: RunnableConfig): state['conference_summary'] = fs_instance.read_file(f"{output_dir}/conference_summary.md") full_text = ( - state['conference_overview'] + '\n\n\n' + - state['conference_submission'] + '\n\n\n' + - state['conference_keynotes'] + '\n\n\n' + - state['conference_topic'] + '\n\n\n' + - state['conference_best_papers'] + '\n\n\n' + - state['conference_summary'] + state.get('conference_overview', '') + '\n\n\n' + + state.get('conference_submission', '') + '\n\n\n' + + state.get('conference_keynotes', '') + '\n\n\n' + + state.get('conference_topic', '') + '\n\n\n' + + state.get('conference_best_papers', '') + '\n\n\n' + + state.get('conference_summary', '') ) # 3. 把输出吐到前端; writer = get_stream_writer() diff --git a/deepinsight/core/prompt/conference_best_papers.py b/deepinsight/core/prompt/conference_best_papers.py index 79406f3a9fb96d3e1da5e904799875a1f7bfdfdd..e90cd24fe683f42eccc004f5f10e2ec8d802ed86 100644 --- a/deepinsight/core/prompt/conference_best_papers.py +++ b/deepinsight/core/prompt/conference_best_papers.py @@ -362,7 +362,7 @@ paper_analysis_no_rag_prompt = r""" - 在生成 Markdown 报告时,按内容语义嵌入图片: - 架构或算法流程图 → “关键技术” - 实验结果图 → “技术效果” -- 保留外链形式 `![](https://...)`,不下载图片 +- 图像引用保持工具返回的原始链接或相对路径,不进行改写或补全;示例:`![<图表标题>](<图表链接或相对路径>)` ◆ 第三步:获取并分析论文内容 - 获取摘要、引言、核心方法、实验结果及结论 @@ -495,8 +495,8 @@ paper_analysis_no_rag_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -505,60 +505,7 @@ paper_analysis_no_rag_prompt = r""" 3. 数据库模型说明: -``` -class Author(PaperBase): - __tablename__ = 'author_table' - - author_id = Column(Integer, primary_key=True, autoincrement=True) - author_name = Column(String(100), nullable=False) - email = Column(String(255)) - affiliation = Column(String(255)) - affiliation_country = Column(String(100)) - affiliation_city = Column(String(100)) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Conference(PaperBase): - __tablename__ = 'conference_table' - - conference_id = Column(Integer, primary_key=True, autoincrement=True) - full_name = Column(String(255), nullable=False) - short_name = Column(String(50)) - year = Column(Integer, nullable=False) - location = Column(String(100)) - start_date = Column(Date) - end_date = Column(Date) - website = Column(String(255)) - topics: Mapped[list[str]] = Column(JSON) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Paper(PaperBase): - __tablename__ = 'paper_table' - paper_id = Column(Integer, primary_key=True, autoincrement=True) - title = Column(String(255), nullable=False) - conference_id = Column(Integer) # 直接存储ID,不使用ForeignKey - publication_year = Column(Integer) - abstract = Column(Text) - keywords = Column(String(255)) - author_ids = Column(String(500)) # 存储作者ID列表,如 "1,3,5" - reference_ids = Column(String(500)) # 存储参考文献ID列表 - topic = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class PaperAuthorRelation(PaperBase): - __tablename__ = 'paper_author_relation_table' - - relation_id = Column(Integer, primary_key=True, autoincrement=True) - paper_id = Column(Integer) # 直接存储ID - author_id = Column(Integer) # 直接存储ID - author_order = Column(Integer, nullable=False) - is_corresponding = Column(Boolean, default=False, nullable=False) - created_at = Column(TIMESTAMP, default=datetime.now) +{{db_models_description}} """ paper_analysis_prompt = r""" @@ -603,20 +550,12 @@ paper_analysis_prompt = r""" **目的**:提取论文中的**核心设计图**与**实验结果图**,辅助后续的结构化报告。 **执行策略**: -* **图像来源与格式强制要求**:优先使用 `retrieval` 工具检索图片资源,且最终在报告中引用的图片**必须**采用下列形式的外链(注意:使用内部IP或确定的IP,不可为 www 域名或外部域名): - - ``` - ![](http://:/parsed-file-images/.jpg) - ``` - - * 要求使用 `http` 协议或根据系统要求的端口(不使用 `www` 或公共域名); - * `` 必须为确定的 IP 地址,跟查询到的IP保持一致,确保准确,不要生成ip,不得使用域名占位符或 CDN 域名; - * 文件路径固定为 `/parsed-file-images/` 目录下的 jpg 文件。 +* **图像来源与格式要求**:优先使用 `retrieval` 工具检索图片资源,最终在报告中引用的图片链接或相对路径应与工具返回值保持一致;示例:`![<图表标题>](<图表链接或相对路径>)`。不得自行构造或替换链接结构。 * 根据论文文本推断图片含义,明确其所表达的内容: * **架构设计/算法流程图** → 表示论文核心技术结构 * **实验结果/对比性能图** → 展示技术效果与性能改进 -* 若 `retrieval` 无图片或不完整,使用网络搜索工具查找相应论文插图;在网络来源获得图片后,若系统流程要求将图片保存到内部解析目录,则需将图片保存为 `parsed-file-images/.jpg` 并在报告中以上述 IP 外链形式引用。若不能保存到该内网路径,必须在报告中明确说明并列出实际可用的图片链接。 +* 若 `retrieval` 无图片或不完整,使用网络搜索工具查找相应论文插图;在网络来源获得图片后,若系统流程要求将图片保存到某目录并返回相对路径或链接,则在报告中按工具返回的路径或链接引用,并在无法保存到预期目录时明确说明并列出实际可用链接或路径。 * **图片筛选规则**:仅保留两张 1. 架构或流程图(体现设计思想) @@ -625,8 +564,7 @@ paper_analysis_prompt = r""" * 架构图 → “关键技术”章节 * 实验图 → “技术效果”章节 -* **强制外链形式**:图片在报告中必须以 `![](http://:/parsed-file-images/.jpg)` 的形式出现,不允许使用内嵌 Base64、附件或其他第三方 CDN 链接。 -* 不可下载或内嵌到 Markdown 文件中;若检索返回的图片原始 URL 不是内部 IP,请在保存步骤中将其转存到内部 `parsed-file-images` 并使用内部 IP 外链(如系统环境允许)。 +* **引用要求**:图片以 Markdown 形式引用为 `![<图表标题>](<图表链接或相对路径>)`,不允许内嵌 Base64 或作为附件。保持与工具返回一致,不强制特定协议或主机格式。 --- @@ -680,7 +618,7 @@ paper_analysis_prompt = r""" * **保存反馈要求**:在保存成功后,必须输出: * 完整文件路径(绝对路径) - * 所使用的图片链接清单,且每个图片链接必须为 `http://:/parsed-file-images/.jpg` 格式 +* 所使用的图片链接或相对路径清单,逐条保持与工具返回一致 * 若保存失败,需说明原因并尝试修复(列出失败原因与下一步修复动作)。 ❗ 注意事项: @@ -736,10 +674,10 @@ paper_analysis_prompt = r""" 用户输入论文名称后,严格按以下顺序执行: 1. 查询论文(优先使用 retrieval) -2. 获取论文相关图片(优先使用 retrieval,最终图片必须为内部 IP 外链形式) +2. 获取论文相关图片(优先使用 retrieval,图片链接或相对路径按工具返回引用) 3. 分析论文内容 -4. 生成报告(图片嵌入语义位置,并以指定 IP 外链引用) -5. 保存 Markdown 文件及图片(并反馈文件路径与图片清单) +4. 生成报告(图片嵌入语义位置,按工具返回的链接或相对路径引用) +5. 保存 Markdown 文件及图片(并反馈文件路径与图片清单,保持链接或路径原样) """ research_system_prompt = r""" diff --git a/deepinsight/core/prompt/conference_qa.py b/deepinsight/core/prompt/conference_qa.py new file mode 100644 index 0000000000000000000000000000000000000000..1778004690fe54f55bfb3f094eab2a772927c070 --- /dev/null +++ b/deepinsight/core/prompt/conference_qa.py @@ -0,0 +1,212 @@ +answer_composer_prompt = fr""" +你是一个答案汇总者(Answer Composer)。 +你的任务是基于用户的问题和提供的信息,生成一个清晰、准确、简洁的最终回答。 + +你将获得: +1. 一份信息列表(可能包含事实、摘要、报告或文档片段)。 +2. 用户的最新问题。 + +要求: +- 仔细理解用户的问题。 +- 综合利用提供的信息来回答,而不是逐字复述。 +- 回答要自然连贯,逻辑清晰。 +- 如果信息存在冲突,请给出合理、平衡的解释。 +- 如有iframe,保留到输出结果。 + +# 交互消息列表: +{{messages}} + +# 输出格式要求:**直接回答用户问题,不要解释别的东西** +""" + +clarify_node_prompt = r""" +当用户的问题模糊不清、缺少关键信息(例如:缺少具体的关键词、时间范围、文档类型等)或者意图不明确时, +调用此员工。它的任务是主动向用户提问,以获取完成后续任务所必需的全部信息。 +""" + +deep_research_team_prompt = r""" +此团队专注于技术领域的深度剖析、趋势预判与核心逻辑拆解,聚焦为用户提供具备专业性、前瞻性的技术洞察。 +主要功能: +1. 拆解特定技术(如 AI、区块链、云计算)的核心原理与架构,分析技术优劣势及优化方向,形成技术细节洞察。 +2. 结合技术脉络与行业场景,预判技术演进趋势,评估对产业的影响,输出趋势洞察报告。 +3. 对比不同技术路径的适配性与风险,为技术选型提供决策洞察支持。 +4. 解读技术前沿动态(如突破性成果、标准更新),提炼核心价值,生成关键信息总结。 +示例提问: +1. 大语言模型注意力机制的核心原理洞察是什么? +2. 未来3年云计算技术趋势的总结怎么写? +3. 工业互联网边缘与云计算协同的技术洞察有哪些? +4. 新量子计算算法的技术突破点总结是什么? +5. 自动驾驶多传感器融合的架构洞察该如何提炼? +""" + +paper_team_prompt = r""" +此团队专注于学术论文的元数据统计与汇总,不解决论文正文/具体技术内容的解析/深度洞察类的问题。 +主要功能包括: +1. 基于会议/期刊论文的元数据(如题目、作者、机构、关键词、摘要、年份、引用数等)进行检索与统计。 +2. 提供论文数量统计、作者/机构分布、主题趋势分析等宏观信息。 +3. 帮助用户快速了解论文集合的整体特征和分布情况,但不回答论文具体研究方法、实验结果或创新点等内容。 +4. 如果用户询问的是需要深入洞察、技术解析或研究建议的问题(例如:"这篇论文的方法论有什么创新?"/"实验结果说明了什么?"/"这个领域未来研究方向是什么?) +不要使用此团队,建议用户使用深度洞察团队。 +""" + +report_chart_agent_sys_prompt = r""" +# 角色 +你是一名专业的数据可视化分析助手,负责基于输入数据调用绘图工具并返回结果。 + +# 任务 +根据输入数据生成图表。工具会返回一个图片的 URL。你必须始终以 JSON 格式输出,且只能包含以下三个字段: +- url: 图表的 URL(字符串)。若图表生成失败或数据不足,则填 ""。 +- description: 针对该图片的简要描述,以及基于数据做的简单分析(字符串)。若图表生成失败则填 ""。 +- question: 当无法生成图表时,写明需要用户补充的信息或说明失败原因(字符串)。若生成成功则填 ""。 + +# 输出要求 +- 输出必须是标准 JSON 对象,键名固定为 "url"、"description"、"question"。 +- 除 JSON 之外,禁止输出任何其他文字、包裹符号、解释、引导语或附加信息。 +- 若图表生成成功:description 必须简洁明了,客观说明图表内容与关键发现。 +- 若图表生成失败:在 question 字段明确告诉用户缺失了什么(如“缺少时间字段,无法绘制折线图”)。 +- 生成图表会返回两种类型的url接口,请根据用户指定确定使用那种url,只能使用一种形式的url,默认采用html格式的url: + 返回数据为html格式url:http://:/api/v1/deepinsight/charts/ + 返回数据为image(png格式)url:http://:/api/v1/deepinsight/charts/image/ +- 使用中文回答。 + +# 输出示例(成功情况) +{ + "url": "http://:/api/v1/deepinsight/charts/", + "description": "折线图显示销售额在3月至8月持续上升,4月有小幅回落后在6月开始快速增长。", + "question": "" +} + +# 输出示例(失败情况) +{ + "url": "", + "description": "", + "question": "无法生成图表:缺少时间字段。请提供完整的按月数据。" +} + +# 约束 +- 在任何情况下都【绝对】不允许编造数据!! +""" + +report_team_prompt = r""" +此团队专注于内容的组织、撰写和最终呈现,如果需要数据来源,需要先调用paper_team进行查询。 +主要功能: +1. 依据用户提供的数据与绘图指令,生成符合要求、清晰美观的图表。 +2. 理解并压缩原文内容,提取关键信息,形成简明扼要的摘要,同时保留原文的主要思想与逻辑。 +""" + +retrieval_team_prompt = r""" +此团队专注于面向具体内容的深入检索,重点回答涉及论文正文、技术细节或跨源知识的问题。 +主要功能包括: +1. 检索和提取某一具体论文的研究方法、实验结果、核心创新点等详细内容。 +2. 支持技术主题的细粒度信息检索与知识点解析(不限于学术论文,还可扩展至知识库、互联网和内网资料)。 +""" + +static_agent_system_prompt = r""" +你是一名专精于学术会议数据检索与处理的资深数据分析师。 +你可以仅使用 PythonREPLTool() 进行数据库查询与基础数据处理。 +所有信息必须直接来源于数据库;不得编造、估算或推断数据。 + +1. **可用工具:** +- PythonREPLTool:仅用于执行数据库查询与简单的数据格式化。 + 示例: + ```python + with Database().get_session() as session: + result = session.execute( + select(Paper.title, Paper.publication_year) + .where(Paper.conference_id == 3) + ).all() + print(result) +```` + +2. **数据库访问(使用 SQLAlchemy Session):** + +```python +from deepinsight.databases.connection import Database +from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation +from sqlalchemy import select, func, desc, distinct + +with Database().get_session() as session: + # 示例:获取指定年份的所有论文 + result = session.execute( + select(Paper.paper_id, Paper.title) + .where(Paper.publication_year == 2021) + ).all() + print(result) +``` + +3. **数据库模型说明:** + *(使用以下模型进行查询、连接和聚合操作;不得假设或引用这些表之外的数据。)* +{{db_models_description}} +--- + +4. **使用准则:** + +* 仅可直接从数据库中获取和处理数据。 +* 除非用户用明确要求,否则不进行进行任何可视化、统计分析或趋势解释,也不拆分相对应的任务。 +* 不得生成、假设或推测数据库中未明确存在的信息。 +* 仅执行用户明确提出的任务或问题。 +* 输出内容应严格限定于回答用户的问题或展示请求的数据,不添加额外评论或分析。 +""" + +supervisor_prompt = r""" +你是一个资深的AI多智能体团队的研究负责人(Team Leader),专注于高层次的研究战略、规划、对员工的高效任务分配。你的核心目标是借助可用的员工最大程度 +地主导研究用户问题。请接收用户的当前请求,制定一个有效的研究计划,并通过将任务委派给合适的员工来完成计划的所有步骤。 +你自己不需要负责具体的任务,计划里面的所有任务都交由可用的员工负责(除非没有可用的员工完成某项步骤你才自己负责具体的任务执行)。 +注意:deep_research_team是针对学术会议的洞察,只要用户任务是针对某个指定的学术会议进行洞察分析,才能指定任务到deep_research_team,例如: 对2025年SSSA学术会议进行洞察 + +# 你的可选员工团队: +{{members}} + +# 员工职责描述: +{{members_description}} +# 工作流程: +遵循以下流程分解用户的问题并制定一个出色的研究计划。要充分、深入地思考用户的任务,全面理解其意图并决定接下来的行动。 +1. 如果用户的请求不够明确、缺乏关键信息,你必须返回 **澄清节点**,并明确告诉用户需要补充哪些信息。 +2. 仔细分析用户的最新请求,根据上述“员工职责描述”对用户问题进行逐步求解,在执行每个子任务时选择最适合的员工并尽量保证让你的员工只专注一件事情。 +3. 请根据用户问题的难易程度决定分的步骤数,你不用一开始制定完整的执行计划,而是一个步骤一个步骤往下执行即可。 +4. 如果需要调用某位员工,你必须对用户的请求进行改写,确保任务描述清晰、完整、自洽,并且包括完成任务所需的数据等完整信息,但是注意不要过度发散。 +5. 如果用户的请求是感谢、打招呼或任务明显已结束,或者是一个无法由任何员工处理的请求,你应该判定任务完成。 +6. **“请补充说明:”。“任务说明:”后面的内容回复语言请保持和用户输入语言一致。** + +# 输出格式要求(返回JSON,只返回以下三种格式之一): +- 如果需要澄清: +{"next": "question_clarify", "task":"<你需要用户补充的内容>"} +- 如果需要分配任务: +{"next": "<成员名称>", "task":"<你重写后的完整任务以及完成任务所需的额外信息和数据>"} +- 如果任务已完成或无需处理: +{"next": "__end__", "task":""} + +# 备注 +# 指令 +- 若解答包含多条数据,必须将数据整理并交由合适的组件生成图表 +- 图表类型仅限 "line" | "bar" | "scatter" | "area" | "pie" | "column" | "radar",禁止使用其他类型 +- 在不同组件之间传递信息时,必须确保数据的完整性,例如涉及论文、作者等信息时需完整传递 +- 当涉及图表生成时,先获取完整数据,再将其交由负责绘制的组件;在非绘图场景下,可直接输出结果 +- 在多轮对话中,即使已有答案,也必须交由相应组件进一步验证或扩展,不得直接回答 +- 当用户输入中包含英文缩写时,指令中必须同时包含缩写和对应的英文全称 + +# 示例: +- 用户输入: "帮我研究一下大模型幻觉的问题" +输出: +{"next": "question_clarify", "task":"你希望研究幻觉问题的角度,例如:成因、评估方法还是解决方案?"} + +- 用户输入: "2024 google IO大会洞察" +输出: +{"next": "deep_research_team", "task":"2024年google IO大会分析"} + +- 用户输入: "查找近三年关于 'Chain of Thought' 在代码生成领域应用的所有高被引论文" +输出: +{"next": "paper_team_node", "task":"请检索近三年关于 'Chain of Thought' 在代码生成领域应用的高被引论文,并输出论文标题、作者、年份和引用次数。"} + +- 用户输入: "查我查询下论文的xxxx的核心创新点" +输出: +{"next": "paper_team_node", "task":"检索论文xxxx的内容,并找到对应的创新点。"} + +- 用户输入: "报告写得不错,多谢" +输出: +{"next": "__end__", "task":""} + +# 注意事项 +- 你必须指派给员工回答问题,而不是自己直接回答!! +- 无论你的团队解决过多少次问题,你的任务都只能是指派给员工[{{member_list}}]中的一个解答,不可以直接回答!! +""" diff --git a/deepinsight/core/prompt/conference_submission.py b/deepinsight/core/prompt/conference_submission.py index 0fa75d45716cb9c106afd556d460fdda359af083..37e249ef34c29775e4d0a1986c14ac58b5619b0f 100644 --- a/deepinsight/core/prompt/conference_submission.py +++ b/deepinsight/core/prompt/conference_submission.py @@ -41,8 +41,8 @@ academic_leaders_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -51,61 +51,7 @@ academic_leaders_prompt = r""" 3. 数据库模型说明: -``` -class Author(PaperBase): - __tablename__ = 'author_table' - - author_id = Column(Integer, primary_key=True, autoincrement=True) - author_name = Column(String(100), nullable=False) - email = Column(String(255)) - affiliation = Column(String(255)) - affiliation_country = Column(String(100)) - affiliation_city = Column(String(100)) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Conference(PaperBase): - __tablename__ = 'conference_table' - - conference_id = Column(Integer, primary_key=True, autoincrement=True) - full_name = Column(String(255), nullable=False) - short_name = Column(String(50)) - year = Column(Integer, nullable=False) - location = Column(String(100)) - start_date = Column(Date) - end_date = Column(Date) - website = Column(String(255)) - topics: Mapped[list[str]] = Column(JSON) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Paper(PaperBase): - __tablename__ = 'paper_table' - - paper_id = Column(Integer, primary_key=True, autoincrement=True) - title = Column(String(255), nullable=False) - conference_id = Column(Integer) # 直接存储ID,不使用ForeignKey - publication_year = Column(Integer) - abstract = Column(Text) - keywords = Column(String(255)) - author_ids = Column(String(500)) # 存储作者ID列表,如 "1,3,5" - reference_ids = Column(String(500)) # 存储参考文献ID列表 - topic = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class PaperAuthorRelation(PaperBase): - __tablename__ = 'paper_author_relation_table' - - relation_id = Column(Integer, primary_key=True, autoincrement=True) - paper_id = Column(Integer) # 直接存储ID - author_id = Column(Integer) # 直接存储ID - author_order = Column(Integer, nullable=False) - is_corresponding = Column(Boolean, default=False, nullable=False) - created_at = Column(TIMESTAMP, default=datetime.now) +{{db_models_description}} ## 🧩 学术角色识别规则(必须严格执行) @@ -484,8 +430,8 @@ high_potential_tech_transfer_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -494,61 +440,7 @@ high_potential_tech_transfer_prompt = r""" 3. 数据库模型说明: -``` -class Author(PaperBase): - __tablename__ = 'author_table' - - author_id = Column(Integer, primary_key=True, autoincrement=True) - author_name = Column(String(100), nullable=False) - email = Column(String(255)) - affiliation = Column(String(255)) - affiliation_country = Column(String(100)) - affiliation_city = Column(String(100)) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Conference(PaperBase): - __tablename__ = 'conference_table' - - conference_id = Column(Integer, primary_key=True, autoincrement=True) - full_name = Column(String(255), nullable=False) - short_name = Column(String(50)) - year = Column(Integer, nullable=False) - location = Column(String(100)) - start_date = Column(Date) - end_date = Column(Date) - website = Column(String(255)) - topics: Mapped[list[str]] = Column(JSON) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Paper(PaperBase): - __tablename__ = 'paper_table' - - paper_id = Column(Integer, primary_key=True, autoincrement=True) - title = Column(String(255), nullable=False) - conference_id = Column(Integer) # 直接存储ID,不使用ForeignKey - publication_year = Column(Integer) - abstract = Column(Text) - keywords = Column(String(255)) - author_ids = Column(String(500)) # 存储作者ID列表,如 "1,3,5" - reference_ids = Column(String(500)) # 存储参考文献ID列表 - topic = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class PaperAuthorRelation(PaperBase): - __tablename__ = 'paper_author_relation_table' - - relation_id = Column(Integer, primary_key=True, autoincrement=True) - paper_id = Column(Integer) # 直接存储ID - author_id = Column(Integer) # 直接存储ID - author_order = Column(Integer, nullable=False) - is_corresponding = Column(Boolean, default=False, nullable=False) - created_at = Column(TIMESTAMP, default=datetime.now) +{{db_models_description}} ## 🧩 分析流程(Execution Requirements) @@ -724,8 +616,8 @@ institution_overview_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -776,32 +668,26 @@ institution_overview_prompt = r""" ## 🧱 输出结构(Output Structure) -1. 图表链接必须使用 **图表生成工具返回的原始链接**,禁止模型自行生成、修改、补全或替换。 -2. 图表链接的结构为: - http:///api/v1/deepinsight/charts/image/ -3. 其中 `` 和 `` **均由图表生成工具返回**,模型不得推测或伪造。 -4. 输出前需逐字符检查链接,确保与工具返回完全一致。 +1. 图表链接或相对路径必须使用图表生成工具返回的原始值,禁止模型自行生成、修改、补全或替换。 +2. 输出前需逐字符检查链接或路径,确保与工具返回完全一致。 ### 一、图表展示 ** 注意(非常重要)** : -1. 图表链接必须使用 **图表生成工具返回的原始链接**,禁止模型自行生成、修改、补全或替换。 -2. 图表链接的结构为: - http:///api/v1/deepinsight/charts/image/ -3. 其中 `` 和 `` **均由图表生成工具返回**,模型不得推测或伪造。 -4. 输出前需逐字符检查链接,确保与工具返回完全一致。 +1. 图表链接或相对路径必须使用图表生成工具返回的原始值,禁止模型自行生成、修改、补全或替换。 +2. 输出前需逐字符检查链接或路径,确保与工具返回完全一致。 #### 1. 机构排名图表 * **图表类型**:条形图 * **内容说明(备注:上述图表的输出说明,最终报告不需要展示)**:展示机构的论文数量排名(Top 10),并以百分比数据展示。 -* **图表链接**:图表展示参考以下格式:![<图表标题>](http://<图表链接地址>) +* **图表链接**:图表展示参考以下格式:![<图表标题>](<图表链接或相对路径>) #### 2. 产学研类型占比分析 * **图表类型**:饼状图 * **内容说明(备注:上述图表的输出说明,最终报告不需要展示)**:高校(含大学及其学院/系)、企业、国家实验室、其他机构(研究院、独立研究所等),并以百分比数据展示。 -* **图表链接**:图表展示参考以下格式:![<图表标题>](http://<图表链接地址>) +* **图表链接**:图表展示参考以下格式:![<图表标题>](<图表链接或相对路径>) **示例表格字段(仅展示占比 ≥ 10% 的技术优势领域):** @@ -993,8 +879,8 @@ inter_institution_collab_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -1003,62 +889,7 @@ inter_institution_collab_prompt = r""" 3. 数据库模型说明: -``` -class Author(PaperBase): - __tablename__ = 'author_table' - - author_id = Column(Integer, primary_key=True, autoincrement=True) - author_name = Column(String(100), nullable=False) - email = Column(String(255)) - affiliation = Column(String(255)) - affiliation_country = Column(String(100)) - affiliation_city = Column(String(100)) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Conference(PaperBase): - __tablename__ = 'conference_table' - - conference_id = Column(Integer, primary_key=True, autoincrement=True) - full_name = Column(String(255), nullable=False) - short_name = Column(String(50)) - year = Column(Integer, nullable=False) - location = Column(String(100)) - start_date = Column(Date) - end_date = Column(Date) - website = Column(String(255)) - topics: Mapped[list[str]] = Column(JSON) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Paper(PaperBase): - __tablename__ = 'paper_table' - - paper_id = Column(Integer, primary_key=True, autoincrement=True) - title = Column(String(255), nullable=False) - conference_id = Column(Integer) # 直接存储ID,不使用ForeignKey - publication_year = Column(Integer) - abstract = Column(Text) - keywords = Column(String(255)) - author_ids = Column(String(500)) # 存储作者ID列表,如 "1,3,5" - reference_ids = Column(String(500)) # 存储参考文献ID列表 - topic = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class PaperAuthorRelation(PaperBase): - __tablename__ = 'paper_author_relation_table' - - relation_id = Column(Integer, primary_key=True, autoincrement=True) - paper_id = Column(Integer) # 直接存储ID - author_id = Column(Integer) # 直接存储ID - author_order = Column(Integer, nullable=False) - is_corresponding = Column(Boolean, default=False, nullable=False) - created_at = Column(TIMESTAMP, default=datetime.now) -``` +{{db_models_description}} --- @@ -1090,17 +921,14 @@ class PaperAuthorRelation(PaperBase): ### 一、图表展示(必须包含) ** 注意(非常重要)** : -1. 图表链接必须使用 **图表生成工具返回的原始链接**,禁止模型自行生成、修改、补全或替换。 -2. 图表链接的结构为: - http:///api/v1/deepinsight/charts/image/ -3. 其中 `` 和 `` **均由图表生成工具返回**,模型不得推测或伪造。 -4. 输出前需逐字符检查链接,确保与工具返回完全一致。 +1. 图表链接或相对路径必须使用图表生成工具返回的原始值,禁止模型自行生成、修改、补全或替换。 +2. 输出前需逐字符检查链接或路径,确保与工具返回完全一致。 #### 1. 跨机构合作权重(必选) * **图表类型**:水平条形图 * **内容说明**:展示网络中权重前5的跨机构合作关系及其合作次数/强度。 -* **图表链接**:图表将在以下 HTTP 链接地址中显示:![<图表标题>](http://<图表链接地址>) +* **图表链接**:图表将在以下链接或相对路径中显示:![<图表标题>](<图表链接或相对路径>) --- ### 二、分析报告 @@ -1284,8 +1112,8 @@ national_tech_profile_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -1294,61 +1122,7 @@ national_tech_profile_prompt = r""" 3. 数据库模型说明: -``` -class Author(PaperBase): - __tablename__ = 'author_table' - - author_id = Column(Integer, primary_key=True, autoincrement=True) - author_name = Column(String(100), nullable=False) - email = Column(String(255)) - affiliation = Column(String(255)) - affiliation_country = Column(String(100)) - affiliation_city = Column(String(100)) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Conference(PaperBase): - __tablename__ = 'conference_table' - - conference_id = Column(Integer, primary_key=True, autoincrement=True) - full_name = Column(String(255), nullable=False) - short_name = Column(String(50)) - year = Column(Integer, nullable=False) - location = Column(String(100)) - start_date = Column(Date) - end_date = Column(Date) - website = Column(String(255)) - topics: Mapped[list[str]] = Column(JSON) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Paper(PaperBase): - __tablename__ = 'paper_table' - - paper_id = Column(Integer, primary_key=True, autoincrement=True) - title = Column(String(255), nullable=False) - conference_id = Column(Integer) # 直接存储ID,不使用ForeignKey - publication_year = Column(Integer) - abstract = Column(Text) - keywords = Column(String(255)) - author_ids = Column(String(500)) # 存储作者ID列表,如 "1,3,5" - reference_ids = Column(String(500)) # 存储参考文献ID列表 - topic = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class PaperAuthorRelation(PaperBase): - __tablename__ = 'paper_author_relation_table' - - relation_id = Column(Integer, primary_key=True, autoincrement=True) - paper_id = Column(Integer) # 直接存储ID - author_id = Column(Integer) # 直接存储ID - author_order = Column(Integer, nullable=False) - is_corresponding = Column(Boolean, default=False, nullable=False) - created_at = Column(TIMESTAMP, default=datetime.now) +{{db_models_description}} --- @@ -1378,17 +1152,14 @@ class PaperAuthorRelation(PaperBase): ### 一、图表展示 ** 注意(非常重要)** : -1. 图表链接必须使用 **图表生成工具返回的原始链接**,禁止模型自行生成、修改、补全或替换。 -2. 图表链接的结构为: - http:///api/v1/deepinsight/charts/image/ -3. 其中 `` 和 `` **均由图表生成工具返回**,模型不得推测或伪造。 -4. 输出前需逐字符检查链接,确保与工具返回完全一致。 +1. 图表链接或相对路径必须使用图表生成工具返回的原始值,禁止模型自行生成、修改、补全或替换。 +2. 输出前需逐字符检查链接或路径,确保与工具返回完全一致。 #### 1. 国家/地区排名图表 * **图表类型**:柱形图 * **内容说明(备注:上述图表的输出说明,最终报告不需要展示)**:展示国家/地区的论文数量排名(Top 10),并以百分比数据展示。 -* **图表链接**:图表展示参考以下格式:![<图表标题>](http://<图表链接地址>) +* **图表链接**:图表展示参考以下格式:![<图表标题>](<图表链接或相对路径>) #### 2. 国家/地区技术优势矩阵 @@ -1497,7 +1268,7 @@ research_hotspots_prompt = r""" # 🎯 学术数据分析助手(Academic Insight Assistant) 你是一名能够通过 **Python 编程与数据库查询** 提取学术会议洞察的智能分析助手。 -你擅长从学术论文数据库中抽取结构化信息、进行统计建模与图表分析(图表链接必须严格使用图表生成工具返回的原始链接),并最终生成高质量的 Markdown 格式分析报告,并输出到指定文件:{{output_file}}。 +你擅长从学术论文数据库中抽取结构化信息、进行统计建模与图表分析(图表链接或相对路径必须严格使用图表生成工具返回的原始值),并最终生成高质量的 Markdown 格式分析报告,并输出到指定文件:{{output_file}}。 --- @@ -1523,8 +1294,8 @@ research_hotspots_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -1533,62 +1304,7 @@ research_hotspots_prompt = r""" 3. 数据库模型说明: -``` -class Author(PaperBase): - __tablename__ = 'author_table' - - author_id = Column(Integer, primary_key=True, autoincrement=True) - author_name = Column(String(100), nullable=False) - email = Column(String(255)) - affiliation = Column(String(255)) - affiliation_country = Column(String(100)) - affiliation_city = Column(String(100)) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Conference(PaperBase): - __tablename__ = 'conference_table' - - conference_id = Column(Integer, primary_key=True, autoincrement=True) - full_name = Column(String(255), nullable=False) - short_name = Column(String(50)) - year = Column(Integer, nullable=False) - location = Column(String(100)) - start_date = Column(Date) - end_date = Column(Date) - website = Column(String(255)) - topics: Mapped[list[str]] = Column(JSON) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class Paper(PaperBase): - __tablename__ = 'paper_table' - - paper_id = Column(Integer, primary_key=True, autoincrement=True) - title = Column(String(255), nullable=False) - conference_id = Column(Integer) # 直接存储ID,不使用ForeignKey - publication_year = Column(Integer) - abstract = Column(Text) - keywords = Column(String(255)) - author_ids = Column(String(500)) # 存储作者ID列表,如 "1,3,5" - reference_ids = Column(String(500)) # 存储参考文献ID列表 - topic = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, default=datetime.now) - updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) - - -class PaperAuthorRelation(PaperBase): - __tablename__ = 'paper_author_relation_table' - - relation_id = Column(Integer, primary_key=True, autoincrement=True) - paper_id = Column(Integer) # 直接存储ID - author_id = Column(Integer) # 直接存储ID - author_order = Column(Integer, nullable=False) - is_corresponding = Column(Boolean, default=False, nullable=False) - created_at = Column(TIMESTAMP, default=datetime.now) -``` +{{db_models_description}} ## 🧩 任务执行要求(Execution Requirements) @@ -1618,23 +1334,20 @@ class PaperAuthorRelation(PaperBase): ### 一、图表展示(必须包含) ** 注意(非常重要)** : -1. 图表链接必须使用 **图表生成工具返回的原始链接**,禁止模型自行生成、修改、补全或替换。 -2. 图表链接的结构为: - http:///api/v1/deepinsight/charts/image/ -3. 其中 `` 和 `` **均由图表生成工具返回**,模型不得推测或伪造。 -4. 输出前需逐字符检查链接,确保与工具返回完全一致。 +1. 图表链接或相对路径必须使用图表生成工具返回的原始值,禁止模型自行生成、修改、补全或替换。 +2. 输出前需逐字符检查链接或路径,确保与工具返回完全一致。 #### 1. 关键词分布图 * **图表类型**:Wordcloud * **内容说明**:展示前50个高频关键词(必须使用英文)及其出现频率,直观呈现研究热点分布。 -* **图表链接**:图表展示参考以下格式:![<图表标题>](http://<图表链接地址>),图表链接必须严格使用图表生成工具返回的原始链接 +* **图表链接**:图表展示参考以下格式:![<图表标题>](<图表链接或相对路径>),必须严格使用图表生成工具返回的原始值 #### 2. 关键词组合分析水平条形图 * **分析逻辑**:针对前50关键词,进行两两组合,统计组合出现频率,输出出现频率最高的前10个组合柱状图。 * **内容说明**:揭示跨领域技术融合趋势和热点关联模式。 -* **图表链接**:图表展示参考以下格式:![<图表标题>](http://<图表链接地址>),图表链接必须严格使用图表生成工具返回的原始链接 +* **图表链接**:图表展示参考以下格式:![<图表标题>](<图表链接或相对路径>),必须严格使用图表生成工具返回的原始值 * **表格字段示例**: @@ -1914,8 +1627,8 @@ tech_topics_prompt = r""" 2. **数据库访问方式**(使用 SQLAlchemy Session): ```python - from databases.connection import Database - from databases.models.conference_paper import Author, Conference, Paper, PaperAuthorRelation + from deepinsight.databases.connection import Database + from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation from sqlalchemy import select, func, desc, distinct with Database().get_session() as session: @@ -1969,16 +1682,13 @@ tech_topics_prompt = r""" ### 一、图表展示(必须包含) ** 注意(非常重要)** : -1. 图表链接必须使用 **图表生成工具返回的原始链接**,禁止模型自行生成、修改、补全或替换。 -2. 图表链接的结构为: - http:///api/v1/deepinsight/charts/image/ -3. 其中 `` 和 `` **均由图表生成工具返回**,模型不得推测或伪造。 -4. 输出前需逐字符检查链接,确保与工具返回完全一致。 +1. 图表链接或相对路径必须使用图表生成工具返回的原始值,禁止模型自行生成、修改、补全或替换。 +2. 输出前需逐字符检查链接或路径,确保与工具返回完全一致。 #### 1. 主题分布图(必选) * **图表类型**:饼状图(主题占比)。 * **内容说明**:展示每个主题在整体论文集合中的占比(若主题数过多,仅展示 Top6, 剩余的并合并“other”)。 -* **图表链接**:图表将在以下 HTTP 链接地址中显示:![<图表标题>](http://<图表链接地址>) +* **图表链接**:图表将在以下链接或相对路径中显示:![<图表标题>](<图表链接或相对路径>) --- diff --git a/deepinsight/core/tools/best_paper_analysis.py b/deepinsight/core/tools/best_paper_analysis.py index 54cbc8c70e36b054018e90400baa15ba47bfaa5f..063668cb45a288b7816d65f8038f7a6ab769c7d7 100644 --- a/deepinsight/core/tools/best_paper_analysis.py +++ b/deepinsight/core/tools/best_paper_analysis.py @@ -11,6 +11,7 @@ from langchain_tavily import TavilySearch from deepinsight.core.tools.file_system import register_fs_tools, MemoryMCPFilesystem from deepinsight.core.utils.research_utils import parse_research_config +from deepinsight.utils.db_schema_utils import get_db_models_source_markdown from deepinsight.core.utils.context_utils import DefaultSummarizationMiddleware # ----------------- 单篇论文解析函数 ----------------- @@ -40,19 +41,19 @@ def analyze_single_paper(paper_info: str, output_dir: str, config: RunnableConfi include_image_descriptions=True, search_depth="advanced", ) - paper_analysis_prompt = rc.prompt_manager.get_prompt( + prompt_template = rc.prompt_manager.get_prompt( name="paper_analysis_no_rag_prompt", group=rc.prompt_group, - ).format(output_dir=output_dir) + ).format(output_dir=output_dir, db_models_description=get_db_models_source_markdown()) tools.append(tavily_instance) if "ragflow" in config["configurable"]: # knowledge_tool = KnowledgeTool() # tools.append(knowledge_tool.knowledge_retrieve) - paper_analysis_prompt = rc.prompt_manager.get_prompt( + prompt_template = rc.prompt_manager.get_prompt( name="paper_analysis_prompt", group=rc.prompt_group, - ).format(output_dir=output_dir) + ).format(output_dir=output_dir, db_models_description=get_db_models_source_markdown()) from deepagents import create_deep_agent # Create the deep agent @@ -60,7 +61,7 @@ def analyze_single_paper(paper_info: str, output_dir: str, config: RunnableConfi agent = create_deep_agent( model=rc.default_model, tools=tools, - system_prompt=paper_analysis_prompt, + system_prompt=prompt_template, ) input_messages = [ { diff --git a/deepinsight/core/tools/file_download.py b/deepinsight/core/tools/file_download.py index f7939726f69d6d3c8cde540d39c2ddf00794beda..f6e635bafcf485b50ed6692c10dfceee67f25f5d 100644 --- a/deepinsight/core/tools/file_download.py +++ b/deepinsight/core/tools/file_download.py @@ -35,23 +35,11 @@ def _is_http_url(url: str) -> bool: return False -def _looks_like_image_by_ext(url: str) -> bool: - """Heuristic: check common image extensions to avoid unnecessary downloads.""" - try: - ext = os.path.splitext(url.split("?")[0])[1].lower() - return ext in {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} - except Exception: - return False - - def _is_web_image(url: str) -> bool: """ - Determine whether the given HTTP/HTTPS URL points to an image resource - using extension heuristics only. Does not perform any header requests. + Determine whether the given URL is a valid HTTP/HTTPS URL. """ - if not _is_http_url(url): - return False - return _looks_like_image_by_ext(url) + return _is_http_url(url) @tool("download_file_from_url", return_direct=False) diff --git a/deepinsight/core/tools/keynote_analysis.py b/deepinsight/core/tools/keynote_analysis.py index e06e39597eb04390271cfe97382b5fd8b2d16137..78d101b505e0448a8b06944a8abecedb9416dc8c 100644 --- a/deepinsight/core/tools/keynote_analysis.py +++ b/deepinsight/core/tools/keynote_analysis.py @@ -142,8 +142,6 @@ def batch_analyze_keynotes( Dict[str, bool]: 每个 keynote 对应的分析成功状态(True/False) """ logging.info(f"接收到 keynotes_info,共 {len(keynotes_info)} 个") - logging.info(f"输出路径: {output_dir}") - logging.info(f"执行配置: {config}") result_map: Dict[str, bool] = {} timeout_seconds = 15 * 60 # 15 分钟超时 diff --git a/deepinsight/core/tools/tavily_search.py b/deepinsight/core/tools/tavily_search.py index 51b74237428c9e16f9bf65e29daa2bb68b3db1b1..6af315af250033c8df4e22564205f5d08a2fc4aa 100644 --- a/deepinsight/core/tools/tavily_search.py +++ b/deepinsight/core/tools/tavily_search.py @@ -149,13 +149,18 @@ async def tavily_search( Formatted string containing summarized search results """ # Step 1: Execute search queries asynchronously - search_results = await tavily_search_async( - queries, - max_results=1, - topic=topic, - include_raw_content=True, - config=config - ) + try: + search_results = await tavily_search_async( + queries, + max_results=1, + topic=topic, + include_raw_content=True, + config=config + ) + except Exception as e: + error_message = f"Tavily search failed with error: {type(e).__name__}: {e}" + logging.error(error_message) + return error_message # Step 2: Deduplicate results by URL to avoid processing the same content multiple times unique_results = {} diff --git a/deepinsight/core/tools/wordcloud_tool.py b/deepinsight/core/tools/wordcloud_tool.py index 530246a252a722caad8db7ed672e55e7170127d3..f70c907c78197dbd68f5c2279adfce5e1164a130 100644 --- a/deepinsight/core/tools/wordcloud_tool.py +++ b/deepinsight/core/tools/wordcloud_tool.py @@ -11,30 +11,50 @@ from deepinsight.config.config import load_config, Config WORK_ROOT: str | None = None CHART_IMAGE_DIR_REL: str | None = None CHART_IMAGE_DIR_ABS: str | None = None +IMAGE_BASE_URL: str | None = None +IMAGE_PATH_MODE: str | None = None def _init_paths_from_config(config_path: str | None): - global WORK_ROOT, CHART_IMAGE_DIR_REL, CHART_IMAGE_DIR_ABS + global WORK_ROOT, CHART_IMAGE_DIR_REL, CHART_IMAGE_DIR_ABS, IMAGE_BASE_URL, IMAGE_PATH_MODE config: Config | None = None - if config_path: + resolved_path = config_path + if resolved_path and os.path.exists(resolved_path): try: - config = load_config(config_path) + config = load_config(resolved_path) except Exception: config = None + else: + fallback = os.path.join(os.getcwd(), "config.yaml") + if os.path.exists(fallback): + try: + config = load_config(fallback) + except Exception: + config = None if config and getattr(config, "workspace", None): WORK_ROOT = config.workspace.work_root or "./data" CHART_IMAGE_DIR_REL = config.workspace.chart_image_dir or "charts" + IMAGE_BASE_URL = ( + config.workspace.image_base_url + or f"http://127.0.0.1:{getattr(config.app, 'port', 8888)}{getattr(config.app, 'api_prefix', '/api/v1')}/deepinsight/charts/image" + ) + IMAGE_PATH_MODE = config.workspace.image_path_mode or "relative" else: WORK_ROOT = "./data" CHART_IMAGE_DIR_REL = "charts" + IMAGE_BASE_URL = None + IMAGE_PATH_MODE = "relative" CHART_IMAGE_DIR_ABS = os.path.abspath(os.path.join(WORK_ROOT, CHART_IMAGE_DIR_REL)) os.makedirs(CHART_IMAGE_DIR_ABS, exist_ok=True) def _rel_tool_path(filename: str) -> str: if WORK_ROOT is None or CHART_IMAGE_DIR_REL is None: - _init_paths_from_config(os.environ.get("DEEPINSIGHT_CONFIG_PATH")) + _init_paths_from_config(None) image_dir_name = CHART_IMAGE_DIR_REL.lstrip("./") if CHART_IMAGE_DIR_REL else "charts" + if (IMAGE_PATH_MODE or "relative").lower() == "base_url" and (IMAGE_BASE_URL or ""): + file_id = os.path.splitext(filename)[0] + return f"{IMAGE_BASE_URL}/{file_id}" return f"../../{image_dir_name}/{filename}" diff --git a/deepinsight/service/ppt/template_service.py b/deepinsight/service/ppt/template_service.py index a448032e8ace96db6f597e225e2efd8f0d8df3a7..32ae17fc33ca790145a0be2f02e923c1b237318f 100644 --- a/deepinsight/service/ppt/template_service.py +++ b/deepinsight/service/ppt/template_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import io import json import os -from typing import Dict, Any, Tuple +from typing import Dict, Any, Tuple, List from pathlib import Path import json import re @@ -20,21 +20,24 @@ SLIDE_INDEX_MAP = { "cover_page": 0, "content_page": 1, "conf_overview_page": 2, - "research_fields_page": 3, - "country_analysis_page": 4, - "institution_analysis_page": 5, - "first_author_page": 6, - "coauthor_page": 7, - "keynote_page": 8, - "topic_content_page": 9, - "topic_detail_page": 10, - "valuable_paper_page": 11, - "conf_summary_page": 12, + "tech_theme_page": 3, + "research_hotspot_collab_01_page": 4, + "research_hotspot_collab_02_page": 5, + "country_tech_feature_page": 6, + "institution_tech_feature_page": 7, + "institution_tech_strength_page": 8, + "institution_cooperation_page": 9, + "high_potential_tech_transfer_page": 10, + "keynote_page": 11, + "topic_content_page": 12, + "topic_detail_page": 13, + "valuable_paper_page": 14, + "conf_summary_page": 15, } TABLE_DEFAULT_STYLE={ "font_name": "微软雅黑", - "font_size": 11, + "font_size": 8, "bold": False, "color": [0, 0, 0], "align": PP_ALIGN.LEFT, @@ -42,7 +45,7 @@ TABLE_DEFAULT_STYLE={ # 表头样式 "header_font_name": "微软雅黑", - "header_font_size": 13, + "header_font_size": 9, "header_bold": True, "header_color": [0, 0, 0], "header_bg_color": [233, 233, 233], # 蓝色背景 @@ -50,13 +53,24 @@ TABLE_DEFAULT_STYLE={ # 尺寸控制 "row_height": None, - "first_col_width": None + "first_col_width": 1.2 } +topic_table_style = deepcopy(TABLE_DEFAULT_STYLE) +topic_table_style["first_col_width"] = 2 +topic_table_style["font_size"] = 11 +topic_table_style["header_font_size"] = 13 +high_potential_table_stype = deepcopy(TABLE_DEFAULT_STYLE) +high_potential_table_stype["first_col_width"] = 3.5 +institution_tech_table_stype = deepcopy(TABLE_DEFAULT_STYLE) +institution_tech_table_stype["first_col_width"] = 2.5 +hotpots_table_stype = deepcopy(TABLE_DEFAULT_STYLE) +hotpots_table_stype["font_size"] = 10 + STYLE_CONFIG = { "cover_page": { - "conference_name": {"font_size": 44, "bold": True, "color": [192, 0, 0], "align": PP_ALIGN.LEFT}, - "date": {"font_size": 24, "bold": False, "color": [0, 0, 0], "align": PP_ALIGN.LEFT}, + "CONFERENCE_NAME": {"font_size": 44, "bold": True, "color": [192, 0, 0], "align": PP_ALIGN.LEFT}, + "DATE": {"font_size": 24, "bold": False, "color": [0, 0, 0], "align": PP_ALIGN.LEFT}, }, "content_page": {}, "conf_overview_page": { @@ -72,24 +86,67 @@ STYLE_CONFIG = { "submit_papers": {"font_size": 12, "bold": False, "color": [50, 50, 50]}, "total_trend": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, }, - "research_fields_page": { - "research_trend": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, - "research_fields_png": {"width": 5, "height": 4}, + + # === 新增:技术主题分析 === + "tech_theme_page": { + "tech_field_png": {}, + "key_tech_intro": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, + "key_tech_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, }, - "country_analysis_page": { - "country_trend": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, - "country_png": {"width": 5, "height": 4}, + + # === 新增:研究热点与跨区域技术合作 01 === + "research_hotspot_collab_01_page": { + "keyword_cloud_png": {}, + "keyword_intro": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, + "keyword_couple_analysis_png": {}, + "keyword_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, }, - "institution_analysis_page": { - "institution_trend": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, - "institution_png": {"width": 5, "height": 4}, + + # === 新增:研究热点与跨区域技术合作 02 === + "research_hotspot_collab_02_page": { + "keyword_topic_csv": hotpots_table_stype, # 重用已有的表格样式或按需替换 + "keyword_topic_intro": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, + "keyword_topic_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, }, - "first_author_page": { - "first_author_statistic_csv": TABLE_DEFAULT_STYLE + + # === 新增:国家/地区技术特征分析 === + "country_tech_feature_page": { + "country_tech_top_png": {}, + "country_tech_strength_csv": TABLE_DEFAULT_STYLE, + "country_tech_intro": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, + "country_tech_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, }, - "coauthor_page": { - "coauthor_statistic_csv": TABLE_DEFAULT_STYLE + + # === 新增:机构技术特征分析 === + "institution_tech_feature_page": { + "top_institution_png": {}, + "institution_tech_feat_intro": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, + "compony_school_analysis_png": {}, + }, + + # === 新增:机构技术优势分析 === + "institution_tech_strength_page": { + "university_tech_strength_csv": institution_tech_table_stype, + "compony_tech_strength_csv": institution_tech_table_stype, + "institution_tech_strength_intro": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, + "institution_tech_strength_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, }, + + # === 新增:跨机构合作网络分析 === + "institution_cooperation_page": { + "institution_cooperation_png": {}, + "institution_cooperation_intro": {"font_size": 11, "bold": False, "color": [0, 0, 0]}, + "institution_cooperation_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, + }, + + # === 新增:高潜技术转化分析 === + "high_potential_tech_transfer_page": { + "high_potential_csv": high_potential_table_stype, + "high_potential_intro": {"font_size": 11, "bold": False, "color": [0, 0, 0]}, + "high_potential_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, + }, + + # === 后续原有页面(序号顺延) === "keynote_page": { "keynote_title": {"font_size": 28, "bold": True, "color": [153, 0, 0]}, "speaker": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, @@ -99,10 +156,11 @@ STYLE_CONFIG = { "keynote_objective": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "keynote_method": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "keynote_inspiration": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, - "keynote_picture": {"width": 3.5, "height": 2.5}, + "keynote_pic1_png": {}, + "keynote_pic2_png": {}, }, "topic_content_page": { - "topic_content_csv": TABLE_DEFAULT_STYLE + "topic_content_csv": topic_table_style }, "topic_detail_page": { "topic_title": {"font_size": 24, "bold": True, "color": [153, 0, 0]}, @@ -113,15 +171,17 @@ STYLE_CONFIG = { "topic_inspiration": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, }, "valuable_paper_page": { - "paper_title": {"font_size": 24, "bold": True, "color": [153, 0, 0]}, + "tech_topic": {"font_size": 11, "bold": True, "color": [255, 255, 255]}, + "paper_title": {"font_size": 9, "bold": False, "color": [0, 0, 0]}, + "paper_headline": {"font_size": 24, "bold": True, "color": [153, 0, 0]}, "paper_background": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "paper_challenges": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "paper_tech_resource": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "paper_key_tech": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "paper_result": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, "paper_summary": {"font_size": 12, "bold": False, "color": [255, 255, 255]}, - "key_tech_png": {"width_cm": 7.4, "height_cm": 3}, - "exp_result_png": {"width_cm": 7.4, "height_cm": 3}, + "key_tech_png": {}, + "exp_result_png": {}, }, "conf_summary_page": { "key_trends": {"font_size": 12, "bold": False, "color": [0, 0, 0]}, @@ -273,7 +333,6 @@ class PPTTemplateService: height = Inches(conf.get("height")) if conf.get("height") else shape.height width = Cm(conf.get("width_cm")) if conf.get("width_cm") else width height = Cm(conf.get("height_cm")) if conf.get("height_cm") else height - print(image_path) try: slide.shapes.add_picture(image_path, left, top, width=width, height=height) except Exception as e: @@ -282,7 +341,6 @@ class PPTTemplateService: slide.shapes._spTree.remove(shape.element) # 删除模板图占位 - def _add_solid_fill_to_tcPr(self, tcPr, rgb_tuple): """ 在单元格 tcPr 内附加一个 @@ -321,48 +379,42 @@ class PPTTemplateService: left, top = template_shape.left, template_shape.top - # 计算总宽度(EMU),优先使用 conf width(英寸),否则用模板占位的宽(EMU) + # sizes width_in = conf.get("width", None) height_in = conf.get("height", None) total_width = Inches(width_in) if width_in else template_shape.width total_height = Inches(height_in) if height_in else template_shape.height - # 创建表格 table_shape = slide.shapes.add_table(n_rows, n_cols, left, top, total_width, total_height) table = table_shape.table - # 样式配置 + # styles font_name = conf.get("font_name", "Microsoft YaHei") font_size = Pt(conf.get("font_size", 11)) if conf.get("font_size") else Pt(11) bold = conf.get("bold", False) - color = conf.get("color", [0, 0, 0]) + color = conf.get("color", [0,0,0]) align = conf.get("align", PP_ALIGN.CENTER) - # header header_font_name = conf.get("header_font_name", font_name) header_font_size = Pt(conf.get("header_font_size", int(font_size.pt)+1)) if conf.get("header_font_size") else Pt(int(font_size.pt)+1) header_bold = conf.get("header_bold", True) - header_color = conf.get("header_color", [255, 255, 255]) + header_color = conf.get("header_color", [255,255,255]) header_align = conf.get("header_align", PP_ALIGN.CENTER) - header_bg_color = conf.get("header_bg_color", None) # RGB or None - bg_color = conf.get("bg_color", None) # RGB or None + header_bg_color = conf.get("header_bg_color", None) - row_height_in = conf.get("row_height", None) # inches or None - first_col_width_in = conf.get("first_col_width", None) # inches or None + row_height_in = conf.get("row_height", None) # inches + first_col_width_in = conf.get("first_col_width", None) # inches - # === 计算并分配列宽(EMU) === - # 如果只有一列,直接使用总宽 + # distribute column widths (same logic as your last version) if n_cols == 1: try: table.columns[0].width = total_width except Exception: pass else: - # 将 first_col_width(若存在)换算为 EMU if first_col_width_in: first_col_emu = Inches(first_col_width_in) remaining = total_width - first_col_emu - # 防御性:如果 remaining <= 0,则把所有列平均 if remaining <= 0: per_col = total_width // n_cols for j in range(n_cols): @@ -371,12 +423,10 @@ class PPTTemplateService: except Exception: pass else: - # 首列 try: table.columns[0].width = first_col_emu except Exception: pass - # 平均分配剩余给其他列 per_col = remaining // (n_cols - 1) for j in range(1, n_cols): try: @@ -384,7 +434,6 @@ class PPTTemplateService: except Exception: pass else: - # 没有首列宽度,所有列等分 per_col = total_width // n_cols for j in range(n_cols): try: @@ -392,7 +441,7 @@ class PPTTemplateService: except Exception: pass - # === 行高 === + # set row heights if provided if row_height_in: try: for r in table.rows: @@ -400,43 +449,121 @@ class PPTTemplateService: except Exception: pass - # 填充数据并应用样式 + # helper: create paragraphs + runs from markdown-like text + bold_pattern = re.compile(r'\*\*(.+?)\*\*') + + def _fill_cell_with_markdown(cell, text, is_header=False): + """ + text: may contain '\n' for multiple paragraphs, and **bold** markers. + Apply header or normal styles accordingly. + """ + # clear existing text_frame + tf = cell.text_frame + tf.clear() + + lines = text.splitlines() + + for idx, line in enumerate(lines): + if "\\n" in line: + line=line.replace("\\n", "\n") + if idx == 0: + para = tf.paragraphs[0] + para.clear() + else: + para = tf.add_paragraph() + para.clear() + + # alignment + try: + para.alignment = header_align if is_header else align + except Exception: + pass + + last = 0 + any_bold = False + for m in bold_pattern.finditer(line): + any_bold = True + s, e = m.span() + # preceding + if s > last: + seg = line[last:s] + if seg: + run = para.add_run() + run.text = seg + # normal run style + if not is_header: + if font_name: run.font.name = font_name + run.font.size = font_size + run.font.bold = bold + run.font.color.rgb = RGBColor(*color) + else: + if header_font_name: run.font.name = header_font_name + run.font.size = header_font_size + run.font.bold = header_bold + run.font.color.rgb = RGBColor(*header_color) + # bold segment + inner = m.group(1) + run = para.add_run() + run.text = inner + if not is_header: + if font_name: run.font.name = font_name + run.font.size = font_size + run.font.bold = True + run.font.color.rgb = RGBColor(*color) + else: + if header_font_name: run.font.name = header_font_name + run.font.size = header_font_size + run.font.bold = True + run.font.color.rgb = RGBColor(*header_color) + last = e + # trailing + if last < len(line): + trailing = line[last:] + if trailing: + run = para.add_run() + run.text = trailing + if not is_header: + if font_name: run.font.name = font_name + run.font.size = font_size + run.font.bold = bold + run.font.color.rgb = RGBColor(*color) + else: + if header_font_name: run.font.name = header_font_name + run.font.size = header_font_size + run.font.bold = header_bold + run.font.color.rgb = RGBColor(*header_color) + + # if no bold at all and no runs created (unlikely), write whole line + if not any_bold and not para.runs: + run = para.add_run() + run.text = line + if not is_header: + if font_name: run.font.name = font_name + run.font.size = font_size + run.font.bold = bold + run.font.color.rgb = RGBColor(*color) + else: + if header_font_name: run.font.name = header_font_name + run.font.size = header_font_size + run.font.bold = header_bold + run.font.color.rgb = RGBColor(*header_color) + + # populate table for i, row_vals in enumerate(rows): + is_header = (i == 0) for j in range(n_cols): val = row_vals[j] if j < len(row_vals) else "" cell = table.cell(i, j) - # 设置文本 - cell.text = str(val) - - # 应用段落与 run 样式 - for p in cell.text_frame.paragraphs: - p.alignment = header_align if i == 0 else align - # 确保至少有 run(通常有) - if not p.runs: - continue - for run in p.runs: - if i == 0: - # header - if header_font_name: - run.font.name = header_font_name - if header_font_size: - run.font.size = header_font_size - run.font.bold = header_bold - run.font.color.rgb = RGBColor(*header_color) - else: - if font_name: - run.font.name = font_name - if font_size: - run.font.size = font_size - run.font.bold = bold - run.font.color.rgb = RGBColor(*color) + # if val is not string, coerce + cell_text = "" if val is None else str(val) + _fill_cell_with_markdown(cell, cell_text, is_header=is_header) - # 表头背景色(用 OxmlElement 安全添加) - if i == 0 and header_bg_color: + # header background if requested + if is_header and header_bg_color: try: tc = cell._tc tcPr = tc.get_or_add_tcPr() - # 添加 solidFill + # add solidFill solidFill = OxmlElement('a:solidFill') srgbClr = OxmlElement('a:srgbClr') hexval = "%02X%02X%02X" % tuple(header_bg_color) @@ -446,22 +573,7 @@ class PPTTemplateService: except Exception: pass - if i > 0 and bg_color: - try: - tc = cell._tc - tcPr = tc.get_or_add_tcPr() - # 添加 solidFill - solidFill = OxmlElement('a:solidFill') - srgbClr = OxmlElement('a:srgbClr') - hexval = "%02X%02X%02X" % tuple(bg_color) - srgbClr.set('val', hexval) - solidFill.append(srgbClr) - tcPr.append(solidFill) - except Exception: - pass - - - # 最后尽量删除模板占位 shape(best-effort) + # remove template placeholder shape (best-effort) try: slide.shapes._spTree.remove(template_shape.element) except Exception: @@ -472,9 +584,11 @@ class PPTTemplateService: Robust Markdown-lite -> PPT paragraphs renderer. Fixes invisible-char-before-* issue by normalizing lines before detecting bullets. Supports: - - bullet lines starting with '* ', '- ', '+ ' (real bullet via para.level=0) - - inline **bold** anywhere (including inside bullets) - - newline -> new paragraph + ◦ bullet lines starting with '* ', '- ', '+ ' (real bullet via para.level=0) + ◦ inline bold anywhere (including inside bullets) using **bold** + ◦ newline -> new paragraph + ◦ color segments using ... or ... (both accepted) + conf supports: font_name, font_size (pt), bold (default), color [R,G,B], align (PP_ALIGN) """ if not hasattr(shape, "text_frame"): @@ -486,7 +600,7 @@ class PPTTemplateService: font_name = conf.get("font_name", "Microsoft YaHei") font_size = Pt(conf.get("font_size")) if conf.get("font_size") else None default_bold = conf.get("bold", None) - color = conf.get("color", None) + conf_color = conf.get("color", None) align = conf.get("align", None) tf = shape.text_frame @@ -494,11 +608,16 @@ class PPTTemplateService: lines = raw_text.splitlines() - def _create_run(paragraph, txt, make_bold=None): + def _create_run(paragraph, txt, make_bold=None, color_rgb=None): + if not txt: + return None run = paragraph.add_run() run.text = txt if font_name: - run.font.name = font_name + try: + run.font.name = font_name + except Exception: + pass if font_size: run.font.size = font_size # bold precedence: explicit make_bold (True/False) > default_bold if provided @@ -506,16 +625,72 @@ class PPTTemplateService: run.font.bold = make_bold elif default_bold is not None: run.font.bold = default_bold - if color: - run.font.color.rgb = RGBColor(*color) + # color: color_rgb (tuple) overrides conf_color + if color_rgb: + try: + run.font.color.rgb = RGBColor(*color_rgb) + except Exception: + pass + elif conf_color: + try: + run.font.color.rgb = RGBColor(*conf_color) + except Exception: + pass return run + # helper: split a line into segments [(color_rgb_or_None, text), ...] + def _split_color_segments(line): + segments = [] + # opening tag: + open_pat = re.compile(r'', + re.IGNORECASE) + # closing tag can be or (allow spaces and case-insensitive) + close_pat = re.compile(r'(?:|<\s*color\s*/\s*>)', re.IGNORECASE) + pos = 0 + while pos < len(line): + m = open_pat.search(line, pos) + if not m: + remainder = line[pos:] + if remainder: + segments.append((None, remainder)) + break + start, end = m.span() + # text before opening tag + if start > pos: + before = line[pos:start] + if before: + segments.append((None, before)) + # parse rgb + try: + r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3)) + # clamp 0..255 + r = max(0, min(255, r)); g = max(0, min(255, g)); b = max(0, min(255, b)) + rgb = (r, g, b) + except Exception: + rgb = None + # find closing tag after the opening tag + close_m = close_pat.search(line, end) + if not close_m: + # no close tag: take rest of line + content = line[end:] + segments.append((rgb, content)) + break + else: + cstart, cend = close_m.span() + content = line[end:cstart] + segments.append((rgb, content)) + pos = cend + # continue scanning after close + return segments + first_para = True + bold_pat = re.compile(r'\*\*(.+?)\*\*') + for orig_line in lines: # Normalize leading characters that commonly break ^\s*\* detection: - # remove BOM, zero-width-space, non-breaking space, left/right quotes, and surrounding control chars line = orig_line.lstrip('\ufeff\u200b\u00A0 \t"\'\u201c\u201d\u2018\u2019') - + if not line.strip(): + continue # After stripping control/invisible chars, detect bullet at line start is_bullet = bool(re.match(r'^[\*\-\+]\s+', line)) @@ -526,7 +701,7 @@ class PPTTemplateService: # Get paragraph: first exists by default if first_para: para = tf.paragraphs[0] - para.clear() # remove any default run + para.clear() first_para = False else: para = tf.add_paragraph() @@ -546,31 +721,33 @@ class PPTTemplateService: except Exception: pass - # parse inline bold segments using non-greedy finditer - last_idx = 0 - bold_found = False - for m in re.finditer(r'\*\*(.+?)\*\*', line): - bold_found = True - s, e = m.span() - # text before bold - if s > last_idx: - seg = line[last_idx:s] - if seg: - _create_run(para, seg, make_bold=None) - # bold text - inner = m.group(1) - _create_run(para, inner, make_bold=True) - last_idx = e - # trailing text after last bold - if last_idx < len(line): - trailing = line[last_idx:] - if trailing: - _create_run(para, trailing, make_bold=None) - - # if no bold matches and paragraph has no runs (shouldn't happen because we added runs), - # ensure we add the whole line - if not bold_found and not para.runs: - _create_run(para, line, make_bold=None) + # split into color segments + segments = _split_color_segments(line) + + # For each color segment, further split into bold/non-bold parts and create runs + for seg_color, seg_text in segments: + if not seg_text: + continue + last_idx = 0 + for m in bold_pat.finditer(seg_text): + s, e = m.span() + # text before bold + if s > last_idx: + pre = seg_text[last_idx:s] + if pre: + _create_run(para, pre, make_bold=None, color_rgb=seg_color) + # bold text (inner) + inner = m.group(1) + _create_run(para, inner, make_bold=True, color_rgb=seg_color) + last_idx = e + if last_idx < len(seg_text): + trailing = seg_text[last_idx:] + if trailing: + _create_run(para, trailing, make_bold=None, color_rgb=seg_color) + + # Ensure at least one run exists + if not para.runs: + _create_run(para, line, make_bold=None, color_rgb=None) def delete_slides(self, pres, indices: List[int]): sldIdLst = pres.slides._sldIdLst diff --git a/deepinsight/service/research/research.py b/deepinsight/service/research/research.py index 5ef12fbdfd76a5c7a16ba7a4def047c8cc5fad45..2f860deb98f723a9f80cc48066389128c367c3a1 100644 --- a/deepinsight/service/research/research.py +++ b/deepinsight/service/research/research.py @@ -14,8 +14,14 @@ from re import U import uuid import os import io -from typing import AsyncGenerator, Any, Dict, Set +import asyncio +from typing import AsyncGenerator, Any, Dict, Set, Tuple +import json +import base64 +import logging +from datetime import datetime +from langchain_core.messages import HumanMessage from langgraph.graph.state import CompiledStateGraph from langfuse.langchain import CallbackHandler @@ -26,11 +32,13 @@ from deepinsight.service.streaming.stream_adapter import StreamEventAdapter from deepinsight.service.ppt.template_service import PPTTemplateService from deepinsight.utils.llm_utils import init_langchain_models_from_llm_config from deepinsight.utils.common import safe_get -from deepinsight.core.agent.conference_research.supervisor import graph as conference_graph +from deepinsight.core.agent.conference_qa.supervisor import graph as conference_qa_graph +from deepinsight.core.agent.conference_research.supervisor import graph as conference_research_graph from deepinsight.core.agent.deep_research.supervisor import graph as deep_research_graph from deepinsight.core.agent.deep_research.parallel_supervisor import graph as parallel_deep_research_graph from deepinsight.core.agent.conference_research.ppt_generate import graph as ppt_generate_graph -from deepinsight.service.schemas.research import ResearchRequest, SceneType, PPTGenerateRequest +from deepinsight.service.schemas.research import ResearchRequest, SceneType, PPTGenerateRequest, PdfGenerateRequest, ArgOptionsGeneric, LLMConfig +from deepinsight.utils.trans_md_to_pdf import save_markdown_as_pdf class ResearchService: @@ -61,8 +69,10 @@ class ResearchService: def _build_graph_config(self, req: ResearchRequest) -> dict: """Build a graph_config with request-first precedence, falling back to config.yaml.""" - # Prefer request-provided LLM configs, else use system defaults - model_configs = req.args.llm_options if (getattr(req, "args", None) and getattr(req.args, "llm_options", None)) else self.config.llms + # Prefer request-provided LLM configs, else use system defaults (wrapped) + model_configs = req.args.llm_options if ( + getattr(req, "args", None) and getattr(req.args, "llm_options", None) + ) else self.get_default_config() models, default_model = init_langchain_models_from_llm_config(model_configs) # Read scenario-specific flags and filters (typed access) with request override @@ -74,7 +84,7 @@ class ResearchService: # Determine prompt group for this scene # Conference graph expects prompts under the 'conference_supervisor' group module - if (req.scene_type or SceneType.DEEP_RESEARCH) == SceneType.CONFERENCE: + if req.scene_type == SceneType.CONFERENCE_RESEARCH: prompt_group = "conference_supervisor" else: # Fallback group name; supervisor graph is only used for conference @@ -140,8 +150,10 @@ class ResearchService: def _select_scene_graph(self, request: ResearchRequest) -> CompiledStateGraph: """根据场景类型选择对应的 LangGraph。""" scene_type = request.scene_type or SceneType.DEEP_RESEARCH - if scene_type == SceneType.CONFERENCE: - return conference_graph + if scene_type == SceneType.CONFERENCE_QA: + return conference_qa_graph + elif scene_type == SceneType.CONFERENCE_RESEARCH: + return conference_research_graph elif scene_type == SceneType.DEEP_RESEARCH: if request.parallel_expert_review_enable and request.review_experts: return parallel_deep_research_graph @@ -157,7 +169,7 @@ class ResearchService: Execute the research chat and yield StreamEvent. Parameters: - - request: ResearchRequest with conversation_id, query and optional args + - request: ResearchRequest with conversation_id, messages and optional args - scene_type: 从请求中读取,选择对应的 graph """ graph_config = self._build_graph_config(request) @@ -170,7 +182,7 @@ class ResearchService: scene_graph = self._select_scene_graph(request) async for event in adapter.run_graph( graph=scene_graph, - query=request.query, + messages=request.messages, graph_config=graph_config, conversation_id=request.conversation_id, ): @@ -180,7 +192,7 @@ class ResearchService: self, *, request: PPTGenerateRequest, - ) -> AsyncGenerator[StreamEvent, None]: + ) -> Tuple[io.BytesIO, str]: """ Generate PPT based on the conversation history. @@ -190,7 +202,7 @@ class ResearchService: # 选择模型配置:优先使用请求参数中的 llm_options,其次使用全局配置 model_configs = request.args.llm_options if ( getattr(request, "args", None) and getattr(request.args, "llm_options", None) - ) else self.config.llms + ) else self.get_default_config() if len(model_configs) == 0: raise ValueError(f"Provide at least one LLM configuration") models, default_model = init_langchain_models_from_llm_config(model_configs) @@ -233,4 +245,140 @@ class ResearchService: pptx_stream = io.BytesIO() prs.save(pptx_stream) pptx_stream.seek(0) - return pptx_stream, output_name \ No newline at end of file + return pptx_stream, output_name + + async def pdf_generate(self, request: PdfGenerateRequest): + conversation_id = request.conversation_id + model_configs = request.args.llm_options if ( + request.args and request.args.llm_options) else self.get_default_config() + if len(model_configs) == 0: + raise ValueError(f"Provide at least one LLM configuration") + models, default_model = init_langchain_models_from_llm_config(llm_config=model_configs) + work_root = os.path.abspath(self.config.workspace.work_root) if getattr(self.config, "workspace", None) else os.path.abspath("./data") + base_dir = os.path.join(work_root, "conference_report_result", conversation_id) + os.makedirs(base_dir, exist_ok=True) + json_path = os.path.join(base_dir, "pdf_content.json") + + if os.path.exists(json_path): + with open(json_path, "r", encoding="utf-8") as f: + cached = json.loads(f.read()) + file_name = cached.get("file_name") + pdf_bytes = base64.b64decode(cached.get("content", "")) + buffer = io.BytesIO(pdf_bytes) + buffer.seek(0) + return buffer, file_name + + ordered_files = [ + "conference_overview.md", + "conference_keynotes.md", + "conference_topic.md", + ] + value_mining_dir = os.path.join(base_dir, "conference_value_mining") + value_mining_files = [ + "tech_topics.md", + "national_tech_profile.md", + "institution_overview.md", + "inter_institution_collab.md", + "research_hotspots.md", + "high_potential_tech_transfer.md", + ] + summary_file = "conference_summary.md" + best_papers_dir = os.path.join(base_dir, "conference_best_papers") + + markdown_parts = [] + + for file_name in ordered_files: + file_path = os.path.join(base_dir, file_name) + if os.path.exists(file_path): + with open(file_path, "r", encoding="utf-8") as f: + markdown_parts.append(f.read()) + + if os.path.exists(value_mining_dir): + for vm_file in value_mining_files: + vm_path = os.path.join(value_mining_dir, vm_file) + if os.path.exists(vm_path): + with open(vm_path, "r", encoding="utf-8") as f: + markdown_parts.append(f.read()) + + if os.path.exists(best_papers_dir): + best_papers = sorted( + [f for f in os.listdir(best_papers_dir) if f.endswith(".md")] + ) + for bp in best_papers: + bp_path = os.path.join(best_papers_dir, bp) + with open(bp_path, "r", encoding="utf-8") as f: + markdown_parts.append(f.read()) + + summary_path = os.path.join(base_dir, summary_file) + if os.path.exists(summary_path): + with open(summary_path, "r", encoding="utf-8") as f: + markdown_parts.append(f.read()) + + if not markdown_parts: + raise FileNotFoundError(f"No markdown files found for conversation_id={conversation_id}") + + merged_markdown = "\n\n---\n\n".join(markdown_parts) + + overview_path = os.path.join(base_dir, "conference_overview.md") + report_name = "未知会议" + if os.path.exists(overview_path): + with open(overview_path, "r", encoding="utf-8") as f: + overview_text = f.read() + + prompt = ( + "请从以下文本中提取会议名称和年份,例如“SOSP 2025”或“NeurIPS 2024”。" + "仅返回会议名与年份,不要包含其他文字。\n\n" + f"文本内容:\n{overview_text}" + ) + try: + response = await default_model.with_retry().ainvoke([HumanMessage(content=prompt)]) + report_name = response.content + report_name = report_name.strip().replace("\n", "").replace(":", ":") + except Exception as e: + logging.warning(f"LLM parse conference name error: {e}") + + now = datetime.now() + time_str = now.strftime("%Y年%m月%d日 %H时%M分%S秒") + time_for_filename = now.strftime("%Y%m%d_%H%M%S") + + header_info = ( + f"作者:DeepInsight顶会助手v0.1.0 \n" + f"部门:中软架构与设计管理部 \n" + f"时间:{time_str} \n\n---\n\n" + ) + + final_markdown = header_info + merged_markdown + + file_name = f"{report_name} 洞察报告-{time_for_filename}.pdf" + buffer = io.BytesIO() + output_pdf_path = os.path.join(base_dir, file_name) + await asyncio.to_thread( + save_markdown_as_pdf, + markdown_content=final_markdown, + output_filename=output_pdf_path, + base_url=base_dir, + ) + pdf_bytes = await asyncio.to_thread(lambda p=output_pdf_path: open(p, "rb").read()) + buffer.write(pdf_bytes) + buffer.seek(0) + + cache_data = {"file_name": file_name, "content": base64.b64encode(buffer.getvalue()).decode("utf-8")} + with open(json_path, "w", encoding="utf-8") as f: + f.write(json.dumps(cache_data, ensure_ascii=False, indent=2)) + + return buffer, file_name + + def get_default_config(self) -> List[ArgOptionsGeneric[LLMConfig]]: + return [ + ArgOptionsGeneric( + type=each.type, + params=LLMConfig( + type=each.type, + model=each.model, + base_url=each.base_url, + api_key=each.api_key, + setting=each.setting, + ), + ) + for each in self.config.llms + ] diff --git a/deepinsight/service/schemas/common.py b/deepinsight/service/schemas/common.py index 9c2bb4d9bca301e1e71cefe75c7a44e5055e908c..6c1dba091d486cacbab711295325eb6d6958448d 100644 --- a/deepinsight/service/schemas/common.py +++ b/deepinsight/service/schemas/common.py @@ -1,8 +1,17 @@ from __future__ import annotations +from typing import Generic, Optional, TypeVar from enum import Enum +from pydantic import BaseModel, Field class OwnerType(str, Enum): """Common owner types for knowledge base binding.""" CONFERENCE = "conference" - # Future owners can be added here, e.g. WORKSPACE = "workspace", USER = "user" \ No newline at end of file + # Future owners can be added here, e.g. WORKSPACE = "workspace", USER = "user" + +T = TypeVar("T") + +class ResponseModel(BaseModel, Generic[T]): + code: int = Field(0, description="Response code") + message: str = Field("ok", description="Response message") + data: Optional[T] = Field(None, description="Response data") \ No newline at end of file diff --git a/deepinsight/service/schemas/research.py b/deepinsight/service/schemas/research.py index 99a24fce1e48f8bffe293fb4e8fdc710ce0ced29..296ebf5426c1eda0f7ec587dc1e458d3987c3f67 100644 --- a/deepinsight/service/schemas/research.py +++ b/deepinsight/service/schemas/research.py @@ -1,25 +1,34 @@ from __future__ import annotations -from typing import Optional, List +from typing import Optional, List, TypeVar, Generic from enum import Enum from pydantic import BaseModel, Field, ConfigDict from deepinsight.core.types.graph_config import SearchAPI from deepinsight.config.llm_config import LLMConfig +from deepinsight.service.schemas.streaming import Message + + +T = TypeVar("T") + +class ArgOptionsGeneric(BaseModel, Generic[T]): + type: str = Field(..., description="Arg option item type") + params: T = Field(..., description="Arg option item params") class SceneType(str, Enum): """场景类型枚举,用于选择具体的图实现。""" DEEP_RESEARCH = "deep_research" - CONFERENCE = "conference" + CONFERENCE_RESEARCH = "conference_research" + CONFERENCE_QA = "conference_qa" class ResearchArgs(BaseModel): """Optional arguments to customize research.""" - llm_options: Optional[List[LLMConfig]] = Field( - default=None, - description="Override default LLM configs; if absent, use config.yaml", + llm_options: Optional[List[ArgOptionsGeneric[LLMConfig]]] = Field( + default=None, + description="LLM arguments" ) @@ -29,7 +38,7 @@ class ResearchRequest(BaseModel): model_config = ConfigDict(use_enum_values=True) conversation_id: str = Field(..., description="Unique identifier of the conversation/session") - query: str = Field(..., description="User input to start or resume research") + messages: List[Message] = Field(..., description="List of messages in the conversation") scene_type: Optional[SceneType] = Field( None, description="Conversation scene type: research or conference", @@ -60,3 +69,8 @@ class PPTGenerateRequest(BaseModel): conversation_id: str = Field(..., description="Unique identifier of the conversation") args: Optional[ResearchArgs] = Field(None, description="Additional arguments for the conversation") + +class PdfGenerateRequest(BaseModel): + conversation_id: str = Field(..., + description="Unique identifier of the conversation") + args: Optional[ResearchArgs] = Field(None, description="Additional arguments for the conversation") diff --git a/deepinsight/service/streaming/stream_adapter.py b/deepinsight/service/streaming/stream_adapter.py index 024ac584b3d7b132fec91360e42da5ba00064c51..93a4acca1322bae1e5e591ec89fc1ff70b7ddefd 100644 --- a/deepinsight/service/streaming/stream_adapter.py +++ b/deepinsight/service/streaming/stream_adapter.py @@ -19,6 +19,8 @@ from deepinsight.service.schemas.streaming import ( MessageContentType as ResponseMessageContentType, MessageToolCallContent, StreamEvent, + Message, + MessageContentType, ) from deepinsight.core.types import ( @@ -57,10 +59,32 @@ class StreamEventAdapter: self.tool_call_stream_block_nodes = set(tool_call_stream_block_nodes or []) self.blocked_tool_names = set(blocked_tool_names or []) + def _convert_messages_to_langchain(self, messages: List[Message]) -> List[Any]: + """Convert List[Message] to List[BaseMessage] for LangChain.""" + langchain_messages = [] + for msg in messages: + if msg.content_type == MessageContentType.plain_text and msg.content.text: + langchain_messages.append(HumanMessage(content=msg.content.text)) + elif msg.content_type == MessageContentType.tool_call and msg.content.tool_calls: + # For tool calls, we might need to create ToolMessage or handle differently + # For now, we'll extract text if available or skip + for tool_call in msg.content.tool_calls: + if tool_call.result: + # If there's a result, create a ToolMessage + tool_content = json.dumps(tool_call.result) if isinstance(tool_call.result, dict) else str(tool_call.result) + langchain_messages.append( + ToolMessage( + content=tool_content, + tool_call_id=tool_call.id or "", + name=tool_call.name or "", + ) + ) + return langchain_messages + async def run_graph( self, graph: CompiledStateGraph, - query: str, + messages: List[Message], graph_config: Optional[RunnableConfig] = None, stream_modes: Optional[List[str]] = None, conversation_id: Optional[str] = None, @@ -69,21 +93,38 @@ class StreamEventAdapter: Parameters - graph: a LangGraph/LangChain graph-like object exposing `astream(...)` - - query: user query string + - messages: list of messages in the conversation - graph_config: configuration dict passed into graph execution - stream_modes: modes requested from graph, e.g. ["messages", "custom", "updates"] """ graph_config = graph_config or {} stream_modes = stream_modes or ["messages", "custom", "updates"] tool_call_accumulator = {} + + # Validate messages + if not messages: + raise ValueError("Messages list cannot be empty") + + # Convert Message to LangChain BaseMessage + langchain_messages = self._convert_messages_to_langchain(messages) + if not langchain_messages: + raise ValueError("No valid messages could be converted from the input messages list") + init_state = { - "messages": [HumanMessage(content=query)], + "messages": langchain_messages, } state: StateSnapshot = graph.get_state(config=graph_config) # Resolve conversation id from function arg first, fallback to graph_config resolved_conversation_id = conversation_id or graph_config.get("conversation_id") + # Extract the last plain text message for resume command if needed + resume_content = "" + for msg in reversed(messages): + if msg.content_type == MessageContentType.plain_text and msg.content.text: + resume_content = msg.content.text + break + # Call the underlying graph's streaming API if not state.interrupts: async for namespace, mode, data in graph.astream( @@ -104,7 +145,7 @@ class StreamEventAdapter: else: async for namespace, mode, data in graph.astream( Command( - resume=query, + resume=resume_content, ), config=graph_config, subgraphs=True, @@ -148,7 +189,7 @@ class StreamEventAdapter: try: parsed = json.loads(message_chunk.content) except Exception as e: - logging.error(f"Failed to parse ToolMessage: {e}, raw={message_chunk.content}") + # logging.error(f"Failed to parse ToolMessage: {e}, raw={message_chunk.content}") parsed = {"raw": message_chunk.content} yield StreamEvent( diff --git a/deepinsight/utils/db_schema_utils.py b/deepinsight/utils/db_schema_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..771e3cde06837529e1014d5dd6975864f16edc3f --- /dev/null +++ b/deepinsight/utils/db_schema_utils.py @@ -0,0 +1,12 @@ +import inspect +from deepinsight.databases.models.academic import Author, Conference, Paper, PaperAuthorRelation + +def get_db_models_source_markdown() -> str: + parts = [] + for model in (Author, Conference, Paper, PaperAuthorRelation): + try: + src = inspect.getsource(model) + except Exception: + src = f"class {model.__name__}: pass" + parts.append(src) + return "```python\n" + "\n\n".join(parts) + "\n```" \ No newline at end of file diff --git a/deepinsight/utils/llm_utils.py b/deepinsight/utils/llm_utils.py index b5966021841725173a333bb9eb3f9b37c0e7d420..03dd460b29528e88d9ffdf8188ed0f3bf3dec0c7 100644 --- a/deepinsight/utils/llm_utils.py +++ b/deepinsight/utils/llm_utils.py @@ -10,6 +10,7 @@ from langchain_openai import ChatOpenAI from deepinsight.config.config import Config from deepinsight.config.llm_config import LLMConfig +from deepinsight.service.schemas.research import ArgOptionsGeneric from lightrag.llm.openai import openai_complete_if_cache @@ -42,7 +43,7 @@ def _normalize_settings_kwargs(setting: Any) -> Dict[str, Any]: def init_langchain_models_from_llm_config( - llm_config: List[LLMConfig], + llm_config: List[LLMConfig | ArgOptionsGeneric[LLMConfig]], ) -> Tuple[Dict[str, BaseChatModel], BaseChatModel]: """ 初始化 LangChain 所需的聊天模型集合,并返回默认模型。 @@ -54,16 +55,42 @@ def init_langchain_models_from_llm_config( models: Dict[str, BaseChatModel] = {} default_model: Optional[BaseChatModel] = None + def _extract_fields(item: Any) -> tuple[str, str, Optional[str], Optional[str], Any]: + """ + Extract (provider, model, base_url, api_key, setting) from either LLMConfig + or ArgOptionsGeneric[LLMConfig]-like objects. + """ + # ArgOptionsGeneric + if hasattr(item, "params") and hasattr(item, "type"): + provider = getattr(item, "type") + params = getattr(item, "params") + model = getattr(params, "model", None) + base_url = getattr(params, "base_url", None) + api_key = getattr(params, "api_key", None) + setting = getattr(params, "setting", None) + return provider, model, base_url, api_key, setting + # Plain LLMConfig + provider = getattr(item, "type", None) + model = getattr(item, "model", None) + base_url = getattr(item, "base_url", None) + api_key = getattr(item, "api_key", None) + setting = getattr(item, "setting", None) + return provider, model, base_url, api_key, setting + for each in llm_config: - key = f"{each.type}:{each.model}" - settings_kwargs = _normalize_settings_kwargs(each.setting) + provider, model_name, base_url, api_key, setting = _extract_fields(each) + if not provider or not model_name: + logging.error(f"Invalid LLM item, missing provider/model: {each}") + continue + key = f"{provider}:{model_name}" + settings_kwargs = _normalize_settings_kwargs(setting) settings_kwargs.setdefault("timeout", 300) try: model = init_chat_model( - model_provider=each.type, - model=each.model, - api_key=each.api_key, - base_url=each.base_url, + model_provider=provider, + model=model_name, + api_key=api_key, + base_url=base_url, **settings_kwargs, ) models[key] = model @@ -77,9 +104,9 @@ def init_langchain_models_from_llm_config( f"Cannot directly init model {key} via init_chat_model, falling back to ChatOpenAI. Error: {e}" ) model = ChatOpenAI( - model=each.model, - api_key=each.api_key, - base_url=each.base_url, + model=model_name, + api_key=api_key, + base_url=base_url, **settings_kwargs, ) models[key] = model @@ -191,4 +218,4 @@ def init_lightrag_llm_model_func(cfg: Config) -> Callable[..., Any]: **merged_kwargs, ) - return llm_model_func \ No newline at end of file + return llm_model_func diff --git a/deepinsight/utils/log_utils.py b/deepinsight/utils/log_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e86de2718a0cbc6bae38103e9859fa2fafed6a9b --- /dev/null +++ b/deepinsight/utils/log_utils.py @@ -0,0 +1,65 @@ +import os +import os.path +import logging +from logging.handlers import RotatingFileHandler + +initialized_root_logger = False + +def get_project_base_directory(): + PROJECT_BASE = os.path.abspath( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), + os.pardir, + os.pardir, + ) + ) + return PROJECT_BASE + +def initRootLogger(logfile_basename: str, log_format: str = "%(asctime)-15s %(levelname)-8s [%(process)d:%(name)s:%(funcName)s:%(lineno)d] %(message)s"): + global initialized_root_logger + if initialized_root_logger: + return + initialized_root_logger = True + + logger = logging.getLogger() + logger.handlers.clear() + log_path = os.path.abspath(os.path.join(get_project_base_directory(), "logs", f"{logfile_basename}.log")) + + os.makedirs(os.path.dirname(log_path), exist_ok=True) + formatter = logging.Formatter(log_format) + + handler1 = RotatingFileHandler(log_path, maxBytes=10*1024*1024, backupCount=5) + handler1.setFormatter(formatter) + logger.addHandler(handler1) + + handler2 = logging.StreamHandler() + handler2.setFormatter(formatter) + logger.addHandler(handler2) + + logging.captureWarnings(True) + + LOG_LEVELS = os.environ.get("LOG_LEVELS", "") + pkg_levels = {} + for pkg_name_level in LOG_LEVELS.split(","): + terms = pkg_name_level.split("=") + if len(terms)!= 2: + continue + pkg_name, pkg_level = terms[0], terms[1] + pkg_name = pkg_name.strip() + pkg_level = logging.getLevelName(pkg_level.strip().upper()) + if not isinstance(pkg_level, int): + pkg_level = logging.INFO + pkg_levels[pkg_name] = logging.getLevelName(pkg_level) + + for pkg_name in ['peewee', 'pdfminer']: + if pkg_name not in pkg_levels: + pkg_levels[pkg_name] = logging.getLevelName(logging.WARNING) + if 'root' not in pkg_levels: + pkg_levels['root'] = logging.getLevelName(logging.INFO) + + for pkg_name, pkg_level in pkg_levels.items(): + pkg_logger = logging.getLogger(pkg_name) + pkg_logger.setLevel(pkg_level) + + msg = f"{logfile_basename} log path: {log_path}, log levels: {pkg_levels}" + logger.info(msg) \ No newline at end of file diff --git a/integrations/mcps/generate_chart.py b/integrations/mcps/generate_chart.py index f0b214821ec547e6d8676bcadc23259907b3eb5d..b4b63312f2b12ae616da9e5da79ed411ef42aee4 100644 --- a/integrations/mcps/generate_chart.py +++ b/integrations/mcps/generate_chart.py @@ -15,16 +15,14 @@ from mcp.server.fastmcp import FastMCP WORK_ROOT: str | None = None CHART_IMAGE_DIR_REL: str | None = None CHART_IMAGE_DIR_ABS: str | None = None +IMAGE_BASE_URL: str | None = None +IMAGE_PATH_MODE: str | None = None -def _resolve_config_path_from_args_env() -> str | None: - """优先使用命令行指定的 config.yaml;否则读取环境变量 DEEPINSIGHT_CONFIG;再回退到当前工作目录下的 config.yaml。""" +def _resolve_config_path() -> str | None: + """优先使用命令行指定的 config.yaml;否则回退到当前工作目录下的 config.yaml。""" if len(sys.argv) == 2: return sys.argv[1] - env_path = os.environ.get("DEEPINSIGHT_CONFIG") - if env_path: - return env_path - # fallback to ./config.yaml fallback = os.path.join(os.getcwd(), "config.yaml") return fallback if os.path.exists(fallback) else None @@ -34,20 +32,35 @@ def _init_paths_from_config(config_path: str | None): - workspace.work_root: 基础工作目录(相对工程根,默认 ./data) - workspace.chart_image_dir: 图片保存目录(相对 work_root,默认 charts) """ - global WORK_ROOT, CHART_IMAGE_DIR_REL, CHART_IMAGE_DIR_ABS + global WORK_ROOT, CHART_IMAGE_DIR_REL, CHART_IMAGE_DIR_ABS, IMAGE_BASE_URL, IMAGE_PATH_MODE config: Config | None = None - if config_path: + resolved_path = config_path + if resolved_path and os.path.exists(resolved_path): try: - config = load_config(config_path) + config = load_config(resolved_path) except Exception as e: - logging.warning(f"Failed to load config via deepinsight loader at {config_path}: {e}. Using defaults.") + logging.warning(f"Failed to load config via deepinsight loader at {resolved_path}: {e}. Using defaults.") + else: + fallback = os.path.join(os.getcwd(), "config.yaml") + if os.path.exists(fallback): + try: + config = load_config(fallback) + except Exception as e: + logging.warning(f"Failed to load default config at {fallback}: {e}. Using defaults.") if config and getattr(config, "workspace", None): WORK_ROOT = config.workspace.work_root or "./data" CHART_IMAGE_DIR_REL = config.workspace.chart_image_dir or "charts" + IMAGE_BASE_URL = ( + config.workspace.image_base_url + or f"http://127.0.0.1:{getattr(config.app, 'port', 8888)}{getattr(config.app, 'api_prefix', '/api/v1')}/deepinsight/charts/image" + ) + IMAGE_PATH_MODE = config.workspace.image_path_mode or "relative" else: WORK_ROOT = "./data" CHART_IMAGE_DIR_REL = "charts" + IMAGE_BASE_URL = None + IMAGE_PATH_MODE = "relative" CHART_IMAGE_DIR_ABS = os.path.abspath(os.path.join(WORK_ROOT, CHART_IMAGE_DIR_REL)) os.makedirs(CHART_IMAGE_DIR_ABS, exist_ok=True) @@ -56,14 +69,13 @@ mcp = FastMCP(name="mcp-chart") def _rel_tool_path(filename: str) -> str: - """将文件名转换为工具返回的相对路径格式 '../..//'""" if WORK_ROOT is None or CHART_IMAGE_DIR_REL is None: - # 若未初始化,按默认进行一次初始化 - _init_paths_from_config(os.environ.get("DEEPINSIGHT_CONFIG_PATH")) - # 规范化 work_root 与相对图片目录,去掉开头的 './' + _init_paths_from_config(None) image_dir_name = CHART_IMAGE_DIR_REL.lstrip("./") if CHART_IMAGE_DIR_REL else "charts" - rel = f"{image_dir_name}/{filename}" - return f"../../{rel}" + if (IMAGE_PATH_MODE or "relative").lower() == "base_url" and (IMAGE_BASE_URL or ""): + file_id = os.path.splitext(filename)[0] + return f"{IMAGE_BASE_URL}/{file_id}" + return f"../../{image_dir_name}/{filename}" def save_chart(fig, width=1000, height=600) -> str: @@ -442,10 +454,9 @@ def generate_radar_chart( # 强制以stdio模式启动,无网络通信 if __name__ == "__main__": - # 支持通过命令行参数或环境变量传入 config.yaml 路径 + # 支持通过命令行参数传入 config.yaml 路径;否则默认当前目录 config.yaml # 命令行:python generate_chart.py /path/to/config.yaml - # 环境变量:export DEEPINSIGHT_CONFIG_PATH=/path/to/config.yaml - cfg_path = _resolve_config_path_from_args_env() + cfg_path = _resolve_config_path() _init_paths_from_config(cfg_path) print("Starting chart generator in STDIO mode (no network required)...") mcp.run(transport="stdio") diff --git a/poetry.lock b/poetry.lock index f8b1e889890ac1298fdf187caa636c44f20a4ef1..fb04d11f3d50c793b58138f4fdb631c487b2dd90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3286,6 +3286,22 @@ files = [ langchain-core = ">=1.0.0,<2.0.0" langchain-openai = ">=1.0.0,<2.0.0" +[[package]] +name = "langchain-experimental" +version = "0.4.0" +description = "Building applications with LLMs through composability" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "langchain_experimental-0.4.0-py3-none-any.whl", hash = "sha256:50306e75218e3a3f002de3dd879d719addd6481284b1a282292fac46a130f4a1"}, + {file = "langchain_experimental-0.4.0.tar.gz", hash = "sha256:16bb5c9810e1908c0e2d82cd000bb434bb437b8977507e4dbfe5f25800f431fd"}, +] + +[package.dependencies] +langchain-community = ">=0.4.0,<1.0.0" +langchain-core = ">=1.0.0,<2.0.0" + [[package]] name = "langchain-google-genai" version = "2.1.12" @@ -8142,4 +8158,4 @@ sqlite-async = ["aiosqlite"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "d11b4565aaf4d89dab71e123839fd8423020ee5f9c4591cc31668c216b8840f4" +content-hash = "731dc6abf0bd030f9824da972e0c268aea8e30e2534c9b2f62f6fe7cf67a81e8" diff --git a/pyproject.toml b/pyproject.toml index 261cd32fdc169f7985d45e462b3fcbf4a9ea4cf2..f5a1bd0502a96e87c0832b70ca805e2c6db19a52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,14 +9,15 @@ readme = "README.md" requires-python = ">=3.11,<3.13" license = { text = "MIT" } -# 应用依赖(按用户提供清单) + dependencies = [ - "langfuse >=3.0.6", - "langgraph >=0.1", + "langfuse >= 3.0.6", + "langgraph >= 1.0.3", "langmem >= 0.0.20", - "langchain >= 1.0", + "langchain >= 1.0.7", + "langchain-experimental >= 0.4.0", + - # llm dependency "langchain-openai >= 1.0", "langchain-deepseek >= 1.0", "langchain-anthropic >= 0.3", @@ -32,9 +33,9 @@ dependencies = [ "langchain-community >= 0.4", "langchain-mcp-adapters >= 0.1", - "SQLAlchemy >=2.0,<3.0", - "alembic >=1.10", - "python-dotenv >=1.0", + "SQLAlchemy >= 2.0, < 3.0", + "alembic >= 1.10", + "python-dotenv >= 1.0", "fastapi >= 0.1", "uvicorn >= 0.10", "tavily-python >= 0.7.13", @@ -46,7 +47,7 @@ dependencies = [ "fastmcp >= 2.0", "arxiv >= 2.0", "kaleido == 0.2.1", - "deepagents >= 0.2", + "deepagents >= 0.2.7", "python-pptx >= 1.0", "plotly >= 6.0", "camel-ai == 0.2.60", @@ -63,7 +64,7 @@ sqlite_async = ["aiosqlite >=0.18"] requires = ["poetry-core>=1.0"] build-backend = "poetry.core.masonry.api" -# Poetry 仍用于打包入口与包收集,保持与项目结构一致 + [tool.poetry] packages = [ { include = "deepinsight" }, diff --git a/templates/conference_template.pptx b/templates/conference_template.pptx index 8eb5a3571f914401b20112f077fc1d0b8985ce08..10570498012bdcc23222f4af8fe0a952e4a5821c 100644 Binary files a/templates/conference_template.pptx and b/templates/conference_template.pptx differ