102400333_詹铖煜_软件工程实践第二次作业

sunny_light233 2026-09-24 22:04:58

软件工程实践第二次作业——与 AI 结对编程(顶会热词统计)

项目内容
这个作业属于哪个课程2601_FZU_SE 软件工程实践
这个作业要求在哪里软件工程实践第二次作业——与 AI 结对编程(顶会热词统计)
这个作业的目标与 AI 结对完成需求分析、交互原型、论文采集与管理、热点分析、自动化测试和华为云部署,并对人机协作过程进行复盘
其他参考文献DBLP Search API、CVF Open Access、ECVA、FastAPI、SQLAlchemy、《构建之法》第 4 章

目录

一、项目与提交信息

1.1 项目简介

PaperPulse 是一个面向计算机视觉科研人员的顶会论文热点分析平台,聚焦 CVPR、ICCV、ECCV 三个会议。平台把“论文获取—信息补全—持久化管理—关键词统计—趋势展示”串成一条可演示的闭环,解决研究人员面对大量论文时难以快速掌握研究方向和年度变化的问题。

前端只使用 HTML、CSS 和原生 JavaScript;后端使用 FastAPI、SQLAlchemy 与 SQLite;论文元数据来自 DBLP,摘要和公开原文链接优先由 CVF Open Access 或 ECVA 补充。项目已部署到华为云 Flexus 应用服务器 L。

1.2 重要链接

内容链接/状态
CodeArts 仓库paper_hot_word_statistics
代码规范codestyle.md
墨刀原型PaperPulse 原型只读分享
公网项目http://1.92.95.60/
API 文档http://1.92.95.60/docs
健康检查http://1.92.95.60/api/health
开发分支dev
目标发布版本1.0.0

1.3 AI 工具说明

工具版本主要用途人工责任
OpenAI CodexGPT-5.6 Sol需求拆解、数据库与接口实现、测试设计、报错定位、部署命令和文档整理决定需求取舍,检查代码,执行测试,核验外部数据与部署结果
墨刀 AImodao-ai-1.1.8生成五页原型草稿、统一视觉变量、梳理页面跳转与弹窗交互对照前端人工调整布局、文案、状态和交互,发布只读链接

AI 不是代码和结论的最终责任主体。所有纳入仓库的代码、提示词结果和文字说明均应由我阅读、运行、修改并承担最终责任。涉及测试结果、服务器状态、论文来源等事实时,只采用实际命令或页面验证得到的结果。

二、PSP 表格

PSP2.1 阶段任务预计耗时实际耗时偏差偏差原因
Planning阅读作业、拆分评分项、制定计划6050-10前期对需求文档比较熟悉,任务拆分比预想顺利,计划编制用时少于预估
Estimate评估五项功能和部署工作量456015一开始低估接口、爬虫模块的边界场景,反复调整各模块工时分配,评估耗时变长
AnalysisNABCD、数据源与统计口径分析9011020调研DBLP、CVF、ECVA数据源,需要梳理字段、清洗规则,部分统计口径存在歧义,查阅大量资料确认
Design Spec墨刀五页原型与交互设计120100-20借助墨刀AI完成基础开发,基础页面搭建效率高,仅修改业务专属交互,节省时间
Design Review对照前端进行人工调整与原型复核607515原型与业务逻辑多处细节不匹配,反复和需求对照修改交互跳转、边界页面
Coding Standard编写/核对 codestyle.md3025-5直接复用成熟代码规范模板,只做少量项目定制修改
Design数据库、接口、采集与统计架构设计12014020数据表之间关联关系复杂,图谱统计、批量采集任务要考虑并发、异常重试,架构方案多次迭代
CodingSQLite 模型与论文 CRUD9080-10CRUD属于模板化开发,ORM工具简化大量基础代码,编码快于预期
CodingDBLP、CVF、ECVA 采集与批量任务15019040网站反爬限制,请求限速、部分页面格式不规则,需要调试解析规则、增加重试、代理容错逻辑,调试时间大幅增加
CodingTOP10、关键词图谱、趋势 API12014525图谱数据聚合运算逻辑复杂,要优化查询性能,多次调优SQL和聚合算法
Coding五页前端、真实 API 接入与响应式适配15017020联调阶段前后端字段不一致,移动端各种屏幕尺寸适配遇到不少样式bug,调试排错耗时多
Code Review人工审查 AI 代码与安全检查608020AI生成代码存在隐藏边界bug、异常未捕获,需要逐行审查,补充异常处理逻辑
Testpytest、语法检查、人工验收与修复12015030测试暴露很多边缘case:爬虫空数据、统计计算偏差、接口异常返回,修复回归测试占用大量时间
Deployment华为云、Nginx、systemd 部署与验证9011525服务器环境依赖安装踩坑,systemd服务配置、Nginx反向代理、静态资源路径多次调试,还要做上线冒烟验证
ReportingREADME、博客、截图/GIF 与发布材料7565-10项目过程中已经随手保存截图,文档有现成模板,整理输出效率更高
Postmortem复盘、Release 和最终检查4540-5项目整体流程清晰,复盘重点问题明确,收尾检查工作比预想快
合计14251595170整体总工时超出预估,架构和数据源调研也比预想麻烦;只有原型、CRUD、文档等复用性高的模块比预计更快完成。

PSP 偏差分析

预计与实际偏差最大的环节是 DBLP、CVF、ECVA 采集与批量任务。主要原因是 网站反爬限制,请求限速、部分页面格式不规则,需要调试解析规则、增加重试、代理容错逻辑,调试时间大幅增加。相对准确的是 复盘、Release 和最终检查,因为 项目整体流程清晰,复盘重点问题明确,收尾检查工作比预想快。下一次我会把外部 API 风险、部署环境差异和人工验收单独列出缓冲时间,而不是只估计编码时间。

三、NABCD 需求分析

3.1 N(Need,需求)

CVPR、ICCV、ECCV 每年收录大量论文。科研人员若逐篇浏览,很难在有限时间内回答以下问题:近几年哪些方向最热门?同一关键词在不同会议上的热度如何变化?某个关键词关联哪些论文?已有论文如何统一查询和维护?

因此平台需要:

  1. 支持单篇题目和批量题目采集,并获得摘要、关键词、公开原文链接等信息;
  2. 支持论文增、删、改、查,兼顾题目精确查询与编号/关键词等模糊查询;
  3. 当本地没有目标论文时,可以继续从公开来源检索和导入;
  4. 计算 Top 10 热门方向,构建可点击的关键词共现图;
  5. 用动态折线图比较 CVPR、ICCV、ECCV 多年热度走势;
  6. 在桌面端和移动端都能清晰使用,并提供可公开访问的部署版本。

3.2 A(Approach,做法,重点)

我采用“分层采集、结构化存储、可解释统计、原生可视化”的方案。

第一层:元数据检索。 输入论文题目后,先调用 DBLP Search API 获取题目、作者、年份、会议、DOI 和文献链接;若 REST 接口返回 403、429 或反爬 HTML,则切换到 DBLP SPARQL 查询。匹配阶段会规范化题目,并用相似度选择最接近的结果,避免仅靠完全相等导致漏检。

第二层:官网补全。 对 CVPR、ICCV 访问 CVF Open Access,对 ECCV 访问 ECVA,补充摘要、详情页与 PDF 链接。官网暂时不可用时仍保留 DBLP 元数据,不让一次外部失败破坏整个导入任务。

第三层:统一存储。 使用 SQLAlchemy 管理 SQLite 中的 papers、keywords、paper_keywords、crawl_tasks 四张表。关键词与论文是多对多关系,采集任务独立保存总数、成功数、失败数与错误摘要,便于追踪批量操作。

第四层:可解释分析。 从标题和摘要中识别常见 CV 复合短语并归一同义表达;未命中词表时对题目分词并过滤停用词。Top 10 使用最近三年 1.0 / 0.75 / 0.5 的时间权重;共现边表示两个关键词出现在同一篇论文中;趋势按“关键词—会议—年份”聚合,并对缺失年份补零。

第五层:交互呈现。 原生 JavaScript 调用同源 REST API,渲染五个页面。关键词节点可点击并展示论文;趋势图可播放、暂停和逐年切换;论文管理支持 CRUD;导入页显示单篇结果、批量进度与任务历史。

这一方案的取舍是“先保证数据链路完整、规则透明、部署简单”,而不是在课程项目中直接引入大模型聚类、微服务或复杂图数据库。SQLite 对单机演示和低并发部署足够,同时 SQLAlchemy 为以后迁移 MySQL 留出空间。

3.3 B(Benefit,好处)

  • 将论文发现、管理和分析集中到同一平台,减少在多个网站之间切换;
  • Top 10、共现图和趋势动画比纯表格更容易发现研究方向;
  • 统计口径透明,可解释每个排名和趋势点的来源;
  • 采集任务可追踪,单篇失败不会让整批任务失败;
  • 原生前端加 SQLite 的部署成本低,适合课程演示,也便于以后扩展。

3.4 C(Competitors,竞争分析,重点)

对比对象优势局限PaperPulse 的差异
DBLP元数据权威、检索范围广主要是文献目录,不直接给热点分析和交互图谱使用 DBLP 作为可靠入口,再补全摘要并进行本地分析
CVF / ECVA提供论文详情、摘要和公开 PDF按会议/年份浏览,跨会议对比不方便聚合三大会议,统一查询、管理和趋势比较
Google Scholar / Semantic Scholar搜索与引用信息丰富数据范围宽,未针对三大 CV 顶会的课程目标定制;公开接口和访问策略不同明确限定 CVPR/ICCV/ECCV,统计逻辑可控且可复现
Excel/手工统计灵活、上手快批量维护、关系图与动态趋势成本高,容易出现重复与口径不一致数据库去重、自动聚合、可视化与网页分享

PaperPulse 不试图替代通用学术搜索引擎,而是聚焦“近年三大 CV 顶会热点观察”这个窄场景。它的核心价值在于把公开论文元数据转化为可管理、可解释、可交互的研究趋势视图。

3.5 D(Delivery,推广与交付)

交付物包括:CodeArts 源码仓库、墨刀可交互原型、华为云公网网站、FastAPI 文档、自动化测试、代码规范和博客。演示时从“导入一篇论文”开始,随后在管理页查询,在总览页点击关键词,最后在趋势页播放多会议曲线,形成完整故事线。

四、原型设计

4.1 工具与设计目标

原型使用墨刀及其 AI 能力完成。第一轮提示词描述了产品定位、五页结构和功能边界;第二轮把现有前端的颜色、字号、间距、圆角、描边和阴影整理为统一规范;第三轮逐页补全表格、弹窗、空状态、加载状态和交互连线。AI 负责快速生成候选布局,我负责对照需求和真实页面调整。

墨刀只用于需求沟通和交互原型,没有把原型平台生成的代码直接用于成品。实际前端由我根据原型使用语义化 HTML、CSS 和原生 JavaScript 实现,并通过真实 API 验证交互。

全局视觉采用现代简约的学术 B 端风格:主色 #4B46E5,增长色 #36A866,页面背景 #F7F8FA,正文分为 #1D2129 / #4E5969 / #86909C 三个层级;卡片使用统一圆角、1px 浅描边和极淡阴影。桌面端采用 12 栅格,移动端将双栏改为纵向排列。

img

4.2 五页信息架构

  1. 热门总览页:KPI、Top 10 热门方向、关键词共现图谱和关联论文;
  2. 热度走势页:关键词、年份范围、三会议折线图、播放/暂停和年份步进;
  3. 论文管理页:组合查询、论文表格、查看、新增、编辑、删除和本地无结果时联网检索;
  4. 爬取导入页:单篇题目识别、批量题目导入、来源选择、任务进度与错误摘要;
  5. 数据说明/详情页:数据来源、字段解释、统计口径、限制和项目信息。

4.3 交互规则和数据流转

  • 顶部导航在五页之间切换,并保持统一全局搜索入口;
  • 点击关键词节点,将关键词名称传给关联论文接口,并在侧栏/结果区展示论文;
  • 趋势页切换关键词或年份后重新请求趋势数据;播放按钮按年份逐帧更新高亮区;
  • 管理页提交新增/编辑/删除后刷新论文列表和分析数据;
  • 查询无本地结果时,用户可选择访问 DBLP 并将结果导入;
  • 导入页单篇和批量任务都回写数据库,任务历史展示成功、重复、失败和错误摘要;
  • 所有危险操作(删除)需要二次确认,网络错误以 Toast 或页内错误提示反馈。

img

4.4 AI 生成后的人工作业

我没有直接把 AI 的第一次输出当作最终原型,而是做了以下调整:

  • 删除纯装饰性的高饱和渐变,降低干扰;
  • 统一导航、筛选器、按钮、卡片、表格和状态标签;
  • 保留真实业务字段,避免用无法实现的虚构指标填充界面;
  • 为批量导入补充部分成功和失败状态;
  • 为趋势动画补充播放、暂停、逐年切换与当前帧说明;
  • 校对五页跳转、弹窗、查询联动和只读分享权限。

五、成品展示与功能验收

5.1 热门总览与 Top 10

首页从 /api/analytics/overview 读取论文总数、本周新增、活跃关键词和近三年 Top 10。排名不是静态演示数据,而是随数据库内容变化;增长率使用柔和绿色或下降色区分。

img

5.2 关键词图谱与关联论文

图谱节点大小反映关联论文数量,节点间连线权重表示共同出现次数。点击节点后调用 /api/keywords/{name}/papers,展示相关论文,实现从宏观热点到微观论文的下钻。

img

5.3 热度走势对比

趋势页支持选择关键词和年份范围,并同时展示 CVPR、ICCV、ECCV 三条曲线。播放时按年份更新当前帧,用户也可以暂停或用左右箭头逐年查看。

img

5.4 论文列表查询与管理

论文管理页支持按题目、编号和关键词模糊查询,也可按会议、年份筛选;题目精确命中时返回对应记录。用户可新增、编辑、查看和删除论文,所有操作通过 REST API 持久化到 SQLite。

img

img

img

img

5.6 单篇爬取与批量导入

单篇导入接收题目以及可选会议/年份,先 DBLP 匹配,再尝试 CVF/ECVA 补全。批量导入一次最多 100 个题目,先去空白和重复项,再逐篇处理;单篇失败不会终止全批,并记录任务状态。

img

img

5.7 数据说明、移动端和 API

数据说明页公开数据来源、字段、统计口径和局限,避免用户把样本内排名误解为整个学术领域的绝对结论。页面在移动端把导航、卡片、表格和图表调整为可阅读布局。

img

img

img

六、系统设计与实现过程

6.1 功能结构图

img

6.2 技术架构与数据流

img

部署时 Nginx 提供公网入口并反向代理给只监听本机的 Uvicorn;FastAPI 同时提供静态前端和 REST API;数据库文件不进入 Git,由服务器持久化并备份。

6.3 数据库设计

img

SQLite 是嵌入式数据库,数据库就是一个文件,不需要额外启动服务,适合本次单机课程项目。它和 MySQL 都支持 SQL,但 MySQL 是独立服务,更适合多实例和高并发。项目通过 SQLAlchemy 隔离数据访问,将来可以更换数据库 URL 并配合迁移工具迁移到 MySQL。

6.4 REST API

方法路径作用
GET/api/papers题目/编号/关键词查询,会议和年份筛选
GET/POST/api/papers/{id}、/api/papers查看和新增论文
PUT/DELETE/api/papers/{id}修改和删除论文
GET/api/search/dblp?q=...DBLP 单篇检索
POST/api/papers/import单篇采集并入库
POST/api/papers/import/batch批量采集,最多 100 个题目
GET/api/crawl-tasks查询采集任务与结果
GET/api/analytics/overviewKPI 与近三年 Top 10
GET/api/analytics/keyword-graph关键词节点与共现边
GET/api/keywords/{name}/papers关键词关联论文
GET/api/analytics/trends三大会议年度趋势

6.5 创新性与合理性

本项目的创新点不在于堆叠模型,而在于把目录检索、官网信息补全、可解释热度、关键词共现和动态趋势组合为一个可追溯闭环。REST 到 SPARQL 的降级、批量任务的部分成功语义以及“样本内排名”限制说明,提高了外部数据不稳定时的可用性和结论透明度。技术选型也与规模相匹配:原生前端降低构建成本,FastAPI 提供清晰接口文档,SQLite 满足单机课程项目,Nginx 与 systemd 保证公网访问和进程守护。

七、数据来源与统计口径

7.1 数据来源与合规

  • DBLP Search API / SPARQL:题目、作者、年份、会议、DOI 和文献链接;
  • CVF Open Access:CVPR、ICCV 的摘要、详情页和公开 PDF;
  • ECVA:ECCV 2022/2024 等公开论文详情、摘要和 PDF。

采集仅用于课程教学。程序设置明确 User-Agent、20 秒超时、最多 3 次有限重试和批量间隔,不进行高并发抓取;只保存论文元数据与公开链接,不在仓库分发论文全文。数据版权归作者和出版方所有。

7.2 关键词抽取

当前版本采用可解释的规则法:

  1. 合并题目和摘要并转为小写;
  2. 匹配计算机视觉常见复合短语,如 Vision-Language Model、Object Detection 等;
  3. 把同义表达归一为统一名称;
  4. 若没有命中词表,则从题目提取英文 token、过滤停用词,并取少量高频词兜底;
  5. 每篇论文最多保留 6 个领域关键词。

规则法的优点是快速、稳定、可解释;局限是无法完全识别新术语和语义近义词,且摘要缺失时结果更依赖题目。后续可在数据量足够时比较 TF-IDF、TextRank 或向量聚类,但本版本不把未经验证的模型结果混入统计。

7.3 热度、增长率、共现与趋势

以数据库最新年份为 Y,最近三年的关键词论文数分别为 cY、cY-1、cY-2:

heat_score = 1.0 × cY + 0.75 × cY-1 + 0.5 × cY-2
growth_rate = (cY - cY-1) / cY-1 × 100%

如果上一年为 0、当年大于 0,增长率记为 100%;两个关键词在同一篇论文中出现一次,共现边权重加 1;趋势按关键词、会议、年份统计去重论文数,对没有论文的年份补 0。Top 10 只反映当前数据库样本,不应解读为三大顶会全量论文的绝对排名。

八、关键代码与设计说明

以下选择的是数据采集、批量容错和三类分析的核心代码,约 300 余行。未用 CRUD 样板或 CSS 凑行数。代码以提交前仓库版本为准,发布博客前若又修改源文件,应重新同步片段。

8.1 DBLP 请求、降级与匹配

有限重试只覆盖超时、网络错误及 429/5xx 等可重试状态;REST 接口出现 403/429、非 JSON 或反爬页面时降级到 SPARQL。这样既避免无限重试,也避免把 HTML 当 JSON 解析。

async def request_with_retry(client: httpx.AsyncClient, method: str, url: str, **kwargs) -> httpx.Response:
    """Send a polite public-data request with a small, bounded retry budget."""
    last_error: httpx.HTTPError | None = None
    for attempt in range(MAX_REQUEST_ATTEMPTS):
        try:
            response = await client.request(method, url, **kwargs)
            if response.status_code not in RETRYABLE_STATUS_CODES:
                response.raise_for_status()
                return response
            last_error = httpx.HTTPStatusError(
                f"retryable response {response.status_code}",
                request=response.request,
                response=response,
            )
        except (httpx.TimeoutException, httpx.NetworkError) as error:
            last_error = error
        if attempt + 1 < MAX_REQUEST_ATTEMPTS:
            await asyncio.sleep(0.5 * (attempt + 1))
    if last_error is not None:
        raise last_error
    raise httpx.RequestError("request failed without a response")


async def search_dblp(title: str, limit: int = 10) -> list[dict]:
    params = {"q": title, "format": "json", "h": min(max(limit, 1), 50), "c": 0}
    async with httpx.AsyncClient(
        timeout=REQUEST_TIMEOUT,
        headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
        follow_redirects=True,
    ) as client:
        try:
            response = await request_with_retry(client, "GET", DBLP_API, params=params)
        except httpx.HTTPStatusError as error:
            if error.response.status_code in {403, 429}:
                return await search_dblp_sparql(title, limit)
            raise
    content_type = response.headers.get("content-type", "")
    if "json" not in content_type.lower() or "Making sure you" in response.text:
        return await search_dblp_sparql(title, limit)
    hits = response.json().get("result", {}).get("hits", {}).get("hit", [])
    results = []
    for hit in hits:
        info = hit.get("info", {})
        authors = info.get("authors", {}).get("author", [])
        results.append(
            {
                "title": str(info.get("title", "")).rstrip("."),
                "authors": _string_list(authors),
                "conference": conference_code(str(info.get("venue", ""))),
                "year": int(info.get("year") or 0),
                "dblp_key": info.get("key"),
                "doi": _first_string(info.get("doi")),
                "paper_url": _first_string(info.get("ee")) or info.get("url"),
            }
        )
    return [result for result in results if result["title"] and result["year"]]


def choose_best_match(
    query: str,
    results: list[dict],
    conference: str | None = None,
    year: int | None = None,
) -> dict | None:
    candidates = results
    if conference:
        candidates = [
            item for item in candidates
            if item["conference"].upper() == conference.upper()
        ]
    if year:
        candidates = [item for item in candidates if item["year"] == year]
    query_normalized = normalize_title(query)
    if not candidates:
        return None
    return max(
        candidates,
        key=lambda item: SequenceMatcher(
            None, query_normalized, normalize_title(item["title"])
        ).ratio(),
    )


async def enrich_from_official(title: str, conference: str, year: int) -> dict:
    conference = conference.upper()
    if conference in {"CVPR", "ICCV"}:
        return await enrich_from_cvf(title, conference, year)
    if conference == "ECCV":
        return await enrich_from_ecva(title, year)
    return {}


def extract_keywords(title: str, abstract: str | None = None) -> list[str]:
    text = f"{title} {abstract or ''}".lower()
    matches = [
        label
        for label, patterns in KEYWORD_PATTERNS.items()
        if any(re.search(pattern, text) for pattern in patterns)
    ]
    if matches:
        return matches[:6]

    title_tokens = [
        token
        for token in re.findall(r"[a-z][a-z0-9]{2,}", title.lower())
        if token not in STOP_WORDS
    ]
    fallback_counts = Counter(title_tokens)
    fallback = [
        token.replace("_", " ").title()
        for token, _count in fallback_counts.most_common(3)
    ]
    return fallback or ["Computer Vision"]

8.2 单篇采集与批量容错

collect_paper 负责 DBLP 匹配、官网补全、去重、关键词同步和事务提交。批量接口先对题目去空白和去重,再逐条捕获错误并累计任务状态;任何一篇失败都不会抹掉此前成功结果。

async def collect_paper(db: Session, payload: ImportRequest) -> tuple[dict, bool, str, str]:
    results = await search_dblp(payload.title, 10)
    match = choose_best_match(payload.title, results, payload.conference, payload.year)
    if match is None:
        raise HTTPException(status_code=404, detail="DBLP 中没有找到匹配论文")
    if payload.conference and match["conference"] == "UNKNOWN":
        match["conference"] = payload.conference.upper()

    existing = find_existing_paper(db, match)
    record_status = "skipped" if existing is not None else "imported"
    official_data = {}
    if payload.source != "dblp":
        try:
            official_data = await enrich_from_official(match["title"], match["conference"], match["year"])
        except httpx.HTTPError:
            official_data = {}
    abstract_for_keywords = official_data.get("abstract") or (existing.abstract if existing else None)
    keyword_names = extract_keywords(match["title"], abstract_for_keywords)

    if existing is None:
        source_suffix = "+official" if official_data else ""
        existing = Paper(
            title=match["title"],
            normalized_title=normalize_title(match["title"]),
            conference=match["conference"],
            year=match["year"],
            authors_text=json.dumps(match["authors"], ensure_ascii=False),
            abstract=official_data.get("abstract"),
            paper_url=official_data.get("paper_url") or match.get("paper_url"),
            pdf_url=official_data.get("pdf_url"),
            dblp_key=match.get("dblp_key"),
            doi=match.get("doi"),
            source=f"dblp{source_suffix}",
        )
        db.add(existing)
    else:
        existing.abstract = official_data.get("abstract") or existing.abstract
        existing.paper_url = official_data.get("paper_url") or existing.paper_url or match.get("paper_url")
        existing.pdf_url = official_data.get("pdf_url") or existing.pdf_url
        existing.authors_text = json.dumps(match["authors"], ensure_ascii=False)
        if official_data:
            existing.source = "dblp+official"
    sync_keywords(db, existing, keyword_names, "extracted")
    try:
        db.commit()
    except IntegrityError as error:
        db.rollback()
        raise HTTPException(status_code=409, detail="相同论文已存在") from error
    db.refresh(existing)
    return paper_to_dict(existing), bool(official_data), match["title"], record_status
async def import_papers_batch(payload: BatchImportRequest, db: Session = Depends(get_db)):
    titles = []
    seen = set()
    for raw_title in payload.titles:
        title = raw_title.strip()
        normalized = normalize_title(title)
        if len(title) >= 2 and normalized not in seen:
            seen.add(normalized)
            titles.append(title)
    if not titles:
        raise HTTPException(status_code=422, detail="批量列表中没有有效的论文题目")

    query_summary = "; ".join(titles[:3])
    if len(titles) > 3:
        query_summary += f" 等 {len(titles)} 篇"
    task = CrawlTask(
        query=query_summary[:600],
        task_type="batch",
        source=payload.source,
        status="running",
        total_count=len(titles),
    )
    db.add(task)
    db.commit()
    db.refresh(task)

    item_results: list[dict] = []
    errors = []
    for index, title in enumerate(titles):
        try:
            paper, enriched, _matched_title, record_status = await collect_paper(
                db,
                ImportRequest(title=title, conference=payload.conference, year=payload.year, source=payload.source),
            )
            message = "论文已存在,已更新可用元数据" if record_status == "skipped" else "论文已采集并入库"
            item_results.append(
                {
                    "query": title,
                    "status": record_status,
                    "message": message,
                    "paper": paper,
                    "enriched_by_official": enriched,
                }
            )
            task = db.get(CrawlTask, task.id)
            task.success_count += 1
        except HTTPException as error:
            db.rollback()
            errors.append(f"{title}: {error.detail}")
            item_results.append({"query": title, "status": "failed", "message": str(error.detail)})
            task = db.get(CrawlTask, task.id)
            task.failed_count += 1
        except httpx.HTTPError as error:
            db.rollback()
            errors.append(f"{title}: {error}")
            item_results.append({"query": title, "status": "failed", "message": "公开数据源请求失败"})
            task = db.get(CrawlTask, task.id)
            task.failed_count += 1
        except Exception:
            db.rollback()
            errors.append(f"{title}: 服务器处理采集任务时发生错误")
            item_results.append({"query": title, "status": "failed", "message": "服务器处理采集任务时发生错误"})
            task = db.get(CrawlTask, task.id)
            task.failed_count += 1
            logger.exception("Unexpected batch import failure for %s", title)
        db.commit()
        if index + 1 < len(titles):
            await asyncio.sleep(0.25)

    task = db.get(CrawlTask, task.id)
    task.status = "failed" if task.success_count == 0 else ("partial" if task.failed_count else "completed")
    task.error_message = "\n".join(errors)[:4000] or None
    task.finished_at = datetime.now(timezone.utc)
    db.commit()
    db.refresh(task)
    return {"task": crawl_task_to_dict(task), "results": item_results}

8.3 Top 10、共现图与趋势

下面的 Top 10 代码展示了 KPI、三年加权热度和增长率的完整计算。关键词图采用同一篇论文内的关键词组合计数构造共现边;趋势接口按“关键词—会议—年份”聚合,并预先为 CVPR、ICCV、ECCV 的缺失年份补零。两者的查询与返回结构可在 backend/analytics.py 和公网 /docs 中核对。

def build_overview(db: Session, limit: int = 10) -> dict:
    """Return KPIs and a recency-weighted ranking for the latest three data years."""
    total_papers = db.scalar(select(func.count(Paper.id))) or 0
    active_keywords = db.scalar(select(func.count(func.distinct(PaperKeyword.keyword_id)))) or 0
    week_ago = datetime.now(timezone.utc) - timedelta(days=7)
    new_this_week = db.scalar(select(func.count(Paper.id)).where(Paper.created_at >= week_ago)) or 0
    latest_year = db.scalar(select(func.max(Paper.year)))
    if latest_year is None:
        return {
            "total_papers": total_papers,
            "new_this_week": new_this_week,
            "active_keywords": active_keywords,
            "window_start_year": None,
            "window_end_year": None,
            "top_directions": [],
        }

    window_start = latest_year - 2
    rows = db.execute(
        select(Keyword.name, Paper.year, func.count(func.distinct(Paper.id)))
        .join(PaperKeyword, PaperKeyword.keyword_id == Keyword.id)
        .join(Paper, Paper.id == PaperKeyword.paper_id)
        .where(Paper.year.between(window_start, latest_year))
        .group_by(Keyword.id, Keyword.name, Paper.year)
    ).all()

    yearly_counts: dict[str, dict[int, int]] = defaultdict(dict)
    for name, year, count in rows:
        yearly_counts[name][year] = int(count)

    ranking = []
    for name, counts in yearly_counts.items():
        paper_count = sum(counts.values())
        heat_score = sum(counts.get(latest_year - offset, 0) * weight for offset, weight in enumerate(RECENCY_WEIGHTS))
        current_count = counts.get(latest_year, 0)
        previous_count = counts.get(latest_year - 1, 0)
        if previous_count:
            growth_rate = round((current_count - previous_count) / previous_count * 100, 1)
        elif current_count:
            growth_rate = 100.0
        else:
            growth_rate = 0.0
        ranking.append(
            {
                "name": name,
                "paper_count": paper_count,
                "heat_score": round(heat_score, 2),
                "growth_rate": growth_rate,
            }
        )

    ranking.sort(key=lambda item: (-item["heat_score"], -item["paper_count"], item["name"].casefold()))
    top_directions = [{"rank": index, **item} for index, item in enumerate(ranking[:limit], start=1)]
    return {
        "total_papers": total_papers,
        "new_this_week": new_this_week,
        "active_keywords": active_keywords,
        "window_start_year": window_start,
        "window_end_year": latest_year,
        "top_directions": top_directions,
    }

关键词图没有逐个节点再次查询数据库,而是先选出 Top N 关键词,再通过 selectinload 一次性加载论文关键词关系,避免 N+1 查询。趋势数据固定只接受三大会议,前端播放功能只改变当前年份高亮,不修改原始统计值。

九、AI 结对协作记录

9.1 协作方式

我把 AI 当作能够快速检索代码上下文、给出候选实现和检查清单的结对伙伴,而不是自动交付者。每个重要任务采用“给出约束—让 AI 读取现状—生成最小修改—人工审查—运行验证—继续迭代”的循环。

人类主要负责:需求优先级、数据来源合规、统计定义、视觉取舍、外部账号与部署授权、实际测试和最终提交。AI 主要负责:拆分任务、生成代码候选、分析错误、补测试、核对文档遗漏。

9.2 案例 A:用墨刀 AI 生成原型并人工收敛

第一次提示词摘要:

为 PaperPulse 生成五页学术论文热点分析平台原型:热门总览、热度走势、
论文管理、爬取导入、数据说明。风格为现代简约学术 B 端,保留 Top 10、
关键词图谱、三会议趋势动画、CRUD、单篇/批量导入等功能。

AI 输出摘要: 生成了五页基础布局、导航、卡片、表格、图谱和趋势图,但颜色、组件状态及页面间交互不完全统一。

第二次提示词改写: 把前端 styles.css 中的颜色、字号、间距、圆角和阴影整理为全局规范,要求复用组件,不修改业务数据;随后逐页要求补充弹窗、空状态、失败状态、播放控制和交互连线。

采纳内容: 五页信息架构、12 栅格、KPI/表格/图表卡片、主次按钮层级。

拒绝/修改内容及原因: 拒绝高饱和渐变和无业务含义的大屏装饰,因为会降低学术工具的可读性;把静态“成功”文案改为可验证状态;补上批量部分失败和删除确认,因为这些是实际业务边界。

img

9.3 案例 B:SQLite、采集器、CRUD 与真实 API 接入

关键提示词:

你觉得论文爬取应该采用什么技术实现,根据作业的要求:论文数据建议以CVPR、ICCV、ECCV三大顶会近若干年(如2022年至今)为主,可通过会议官网或DBLP公开数据(提供公开检索API与数据集下载)等渠道获取(先给我一个思路),论文的管理是否需要使用数据库
第一阶段先完成:
1. SQLite 数据表;
2. DBLP 单篇检索;
3. CVF 页面解析;
4. 论文 CRUD 接口;
5. 前端接入真实 API。
保留原生 HTML/CSS/JavaScript,不使用 npm;数据库文件和密钥不得提交。

AI 输出摘要: 建议用 FastAPI + SQLAlchemy + SQLite 建立 papers、keywords、关联表和任务表;实现 DBLP 检索、CVF/ECVA 补全、CRUD 路由和前端 fetch。

人工判断与迭代: 我接受 SQLite,因为本作业是单机、低并发演示,且 SQLAlchemy 可支持未来迁移;没有接受立即引入 MySQL、Docker 或微服务,因为会增加部署复杂度却不能直接提升基础功能得分。之后继续要求加入批量去重、单篇失败不终止、任务状态、真实分析 API 和测试隔离数据库。

验证: 对 CRUD、单篇/批量导入、分析接口和失败路径编写 pytest;外部 DBLP/CVF/ECVA 请求使用 Mock,避免测试依赖实时网络或污染正式数据库。

img

9.4 案例 C:DBLP 反爬响应导致导入失败

现象: 单篇导入时,DBLP REST 端点可能返回 403/429,或返回包含 “Making sure you” 的 HTML 页面。如果直接调用 response.json(),就会出现解析错误,前端只看到笼统的后端失败。

给 AI 的调试提示词摘要:

导入论文时后端报错。请根据日志检查 DBLP 响应状态、Content-Type 和响应体,
不要假设所有 200 都是 JSON。给出最小修复,并保证有限重试、礼貌访问、
失败信息可理解,不能让一篇失败中止整批。

AI 建议与人工修改: AI 建议增加 User-Agent、重试和异常捕获。我进一步要求检查 Content-Type 和反爬页面文本,并在 403/429 或非 JSON 时切换 DBLP SPARQL,而不是无限重试同一 REST 请求;同时限制最大尝试次数并在批量任务间暂停 0.25 秒。

采纳理由: SPARQL 仍是 DBLP 公开接口,保持数据来源一致;有限降级比伪造结果或无休止重试更安全。

回归验证: 单元测试 Mock 403/429、非 JSON、官网失败和单篇异常,确认能降级或返回明确失败,并确认批量任务的其他条目继续执行。

img

9.5 AI 代码与人工代码的界定

本项目不是把某些文件简单标记为“AI 写”或“人写”,因为代码经历了多轮共同修改。更准确的界定如下:

  • AI 提供初始模型、路由、解析器、测试和文档候选;
  • 我决定数据库和部署方案、接口边界、统计公式、页面信息架构与验收标准;
  • 我逐段检查 AI 输出是否符合已有代码、数据源规则和作业限制;
  • 我通过测试、浏览器操作和公网访问验证结果,对失败继续追问并修改;
  • 最终提交的每行代码都由我负责,不因其来源于 AI 而免除解释和维护责任。

9.6 AI 的风险与应对

风险本项目中的表现应对方式
幻觉可能编造不存在的 API 字段、原型状态或“测试已通过”查官方文档、读取实际响应、运行命令;没有证据就写 TODO
知识截止/时效性外部 API、依赖版本、云控制台界面会变化使用当前环境与官方页面复核,不只依赖模型记忆
安全AI 可能建议粘贴密码、开放过多端口或提交 .env不向对话提供密码/私钥;只开放必要端口;.gitignore 排除数据库和密钥
版权与合规大量抓取或保存论文全文可能不合适控制频率,只保存元数据与公开链接,注明教学用途和来源
过度设计可能建议微服务、复杂框架或无关新功能用评分项和 P0/P1 优先级约束范围,先完成五项基础功能

十、测试、Git 与部署

10.1 自动化测试

提交前执行:

.\.venv\Scripts\python.exe -m compileall -q backend tests
.\.venv\Scripts\python.exe -m pytest -q
node --check app.js

本次实际结果:Python 编译检查通过;pytest 为 9 passed, 1 warning,耗时约 0.49s;JavaScript 语法检查通过。唯一警告来自 Starlette TestClient 使用的 AnyIO 兼容别名弃用,不影响当前测试结果,但后续升级依赖时需要复查。

自动化测试使用临时 SQLite 数据库并 Mock 外部网络,不读写正式 paperpulse.db。人工验收清单位于 docs/acceptance-checklist.md,发布前需要把其中的实际结果和截图编号补齐。

10.2 Git/CodeArts 使用情况

  • 采用 dev 开发分支;
  • 当前仓库可核验到 18 次提交,满足不少于 15 次提交的要求;
  • 提交内容按功能、测试、文档和部署准备拆分,使用 feat/test/docs/chore 等前缀;
  • .gitignore 排除了 .venv、数据库、缓存、密钥、日志和部署压缩包;
  • README 包含作业、学号、数据来源、AI 工具、本地运行、测试和部署说明;
  • codestyle.md 参考 PEP 8、FastAPI、Google JavaScript Style Guide、MDN 和 Conventional Commits。

img

10.3 华为云部署

服务器为华为云 Flexus 应用服务器 L,Ubuntu 22.04,2 vCPU、2 GiB 内存、40 GiB 系统盘。Nginx 监听 80 端口,反向代理到只监听 127.0.0.1:8000 的 Uvicorn;systemd 负责进程守护,SQLite 文件保存在服务器持久化目录。

部署后的检查包括:

systemctl is-active nginx
systemctl is-active paperpulse
curl -I http://127.0.0.1/
curl http://127.0.0.1/api/health

2026-09-24 实际核验中,公网首页、/api/health 与 /docs 均返回 HTTP 200;服务器端自动化测试为 9 项通过。公网访问地址为 http://1.92.95.60/。

十一、人机结对开发复盘

11.1 与《构建之法》第 4 章的联系

《构建之法》第 4 章讨论两人合作、代码规范、设计质量和结对编程。传统结对中,驾驶员负责输入代码,领航员持续审查设计与风险,双方通过即时沟通共享上下文。本次人机结对与之相似:我向 AI 描述当前目标和约束,AI 快速给出候选实现,我再审查、运行和追问。

两者也有本质差异。人类伙伴能基于共同经历理解隐含背景,并对承诺、伦理和结果承担责任;AI 的速度和知识覆盖更强,但可能自信地给出错误答案,也不会自动知道本机、云服务器和第三方网站的真实状态。因此与 AI 结对时,“领航员”的审查职责更重,必须把可执行测试、官方资料和实际页面作为共同事实来源。

11.2 心路历程与收获(请本人个性化修改)

项目开始时,我更关注页面是否好看,容易把论文热词平台理解成几张静态图表。随着需求拆分,我意识到真正困难的是建立可信的数据闭环:关键词从哪里来、摘要缺失怎么办、重复论文如何处理、某篇失败是否会破坏整批、图表中的每个数字能否追溯到数据库。这个转变让我把“能展示”进一步理解为“可解释、可验证、可维护”。

AI 明显加快了陌生技术的学习和样板代码的构建,尤其是在 FastAPI 路由、SQLAlchemy 查询、Mock 测试和部署配置方面。但调试外部 API 时,我也体会到不能只相信一次回答。DBLP 返回的内容不一定是 JSON,官网结构可能变化,端口可能被系统占用,云端与本地环境也不一致。只有阅读日志、检查响应和做回归测试,建议才会变成可靠实现。

这次实践最大的收获有三点:第一,先把需求变成可验收条目,再让 AI 编码,返工会少很多;第二,统计平台必须公开数据来源、计算口径和局限,图表好看不代表结论可靠;第三,Git 提交、测试、部署和博客证据不是最后“补材料”,而应贯穿开发过程。

11.3 对 AI 结对伙伴的评价

我认为 AI 最有价值的能力是快速生成候选方案、跨文件读取上下文和在重复迭代中保持结构化输出。它让一个人也能获得类似代码审查和问答伙伴的反馈密度。对本项目而言,AI 对任务拆解、异常分支、测试覆盖和部署排查贡献明显。

它的局限同样清楚:可能忽略当前仓库状态、假设外部接口稳定、给出过度复杂方案,或把尚未执行的命令描述成已完成。AI 也无法代替我确认视觉是否符合预期、数据是否有代表性以及哪些风险可以接受。综合来看,我会继续使用 AI 作为高效率的“候选方案生成器和审查助手”,但不会把它当作事实来源或最终责任人。

11.4 人机结对与人人结对的比较

维度人机结对人人结对
响应速度随时可用,生成和改写快需要协调时间,沟通速度受双方影响
知识覆盖覆盖面广,适合快速探索陌生技术取决于伙伴经验,但领域判断常更深
上下文理解依赖提示词和可读文件,容易遗漏隐含背景能通过长期合作理解目标和团队习惯
审查可靠性可能幻觉,必须由人验证也会犯错,但可追问依据并共同承担责任
创意与取舍能给出大量候选方案更理解用户、课程和现实成本
安全与责任不应提供秘密;最终责任在使用者可通过团队制度分担权限和责任

理想做法不是用 AI 完全替代人,而是让 AI 承担快速检索、样板和初步检查,人负责问题定义、证据验证、架构取舍、伦理安全和最终交付。

通过本次作业,我完成了从需求分析、原型设计、全栈实现、测试到云端部署的完整软件工程流程,也更清楚地认识到:AI 能显著提高探索和实现速度,但产品质量最终来自清晰的需求、可追溯的数据、持续验证和开发者对结果的负责。

...全文
73 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

88

社区成员

发帖
与我相关
我的任务
社区描述
计算机-软件工程
软件工程 高校 福建省·福州市
社区管理员
  • FZU_SE_LQF
  • *奈落*
  • 助教李烨
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧