diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 00000000..a7714dac --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,7 @@ +# 智能体配置文件 + +## 技能目录 + +OpenCode等可以识别`.agents/skills//SKILL.md`。 + +技能文件夹命名遵循“领域-名称“,如`docs-format`。 diff --git a/.agents/skills/devops-commit/SKILL.md b/.agents/skills/devops-commit/SKILL.md new file mode 100644 index 00000000..4cc223e3 --- /dev/null +++ b/.agents/skills/devops-commit/SKILL.md @@ -0,0 +1,134 @@ +--- +name: devops-commit +description: Git 提交技能,用于提交代码变更时,包括主仓库和子模块提交。 +--- + +# Git 提交技能 + +## 提交规范 + +使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范: + +``` +: +``` + +### 提交类型 + +- **feat**:新功能 +- **fix**:修复 bug +- **docs**:文档更新 +- **test**:测试相关 +- **refactor**:代码重构 +- **chore**:构建/工具 + +### 提交示例 + +```bash +git commit -m "feat: 添加新功能" +git commit -m "fix: 修复登录问题" +git commit -m "docs: 更新 README" +``` + +## 提交流程 + +### 1. 检查状态 + +```bash +git status +``` + +查看未提交的变更,包括: +- 未暂存的修改(Changes not staged for commit) +- 已暂存的修改(Changes to be committed) + +### 2. 添加文件 + +```bash +# 添加单个文件 +git add + +# 添加所有修改 +git add -A +``` + +### 3. 提交 + +```bash +git commit -m ": " +``` + +### 4. 确认并推送 + +```bash +git status +git push +``` + +确认提交成功并推送到远端。除非用户明确说"只提交不推",否则默认推送。 + +## 子模块提交 + +### 1. 子模块内提交 + +```bash +# 进入子模块目录 +cd docs/handbook + +# 检查状态 +git status + +# 添加并提交 +git add -A +git commit -m "docs: 更新文档" + +# 推送到远程 +git push +``` + +### 2. 主仓库更新子模块引用 + +```bash +# 返回主仓库 +cd ../.. + +# 添加子模块引用 +git add docs/handbook + +# 提交 +git commit -m "chore: update handbook submodule" + +# 推送 +git push +``` + +## 常见场景 + +### 场景一:普通代码提交 + +```bash +git add -A +git commit -m "feat: 添加用户认证功能" +git push +``` + +### 场景二:文档更新 + +```bash +git add docs/README.md +git commit -m "docs: 更新使用说明" +git push +``` + +### 场景三:子模块更新 + +```bash +cd docs/gallery +git add -A +git commit -m "docs: 添加新示例" +git push +cd .. +git add docs/gallery +git commit -m "chore: update gallery submodule" +git push +``` diff --git a/.agents/skills/devops-release/SKILL.md b/.agents/skills/devops-release/SKILL.md new file mode 100644 index 00000000..7a414b7a --- /dev/null +++ b/.agents/skills/devops-release/SKILL.md @@ -0,0 +1,198 @@ +--- +name: devops-release +description: 发布 Git 仓库 Release。必须先写 CHANGELOG 再打 tag,禁止跳步。支持子模块和主仓库两种流程。 +--- + +# devops-release + +> **⚠ 硬约束:不执行预检查 → 禁止发布** +> 加载此 Skill 后,必须按下方工作流从头到尾逐行执行命令。 +> 标有"必须执行,不可跳过"的步骤是强制性的,AI 不得合并、跳过或提前执行后续步骤。 + +发布 Git 仓库 Release。 + +## 规则 + +- 版本号遵循 semver(MAJOR.MINOR.PATCH) +- **必须先更新 CHANGELOG.md,提交推送,再执行发布** +- 发布前确认工作区干净 +- Release notes 只包含对应版本内容 +- 发布主仓库前确认所有子模块引用是最新的 + +## 依赖 + +- devops-commit: 检查工作区状态 +- devops-submodule: 检查子模块状态 + +## 工作流 + +### 1. 预检查 + +**必须执行,不可跳过** + +```bash +# 检查工作区状态 +git status + +# 检查版本号格式(semver) +VERSION="v0.4.0" +if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "错误: 版本号格式错误,应为 vX.Y.Z 或 vX.Y.Z-qualifier" + exit 1 +fi + +# 检查 CHANGELOG 是否包含目标版本 +if ! grep -q "^## \[${VERSION#v}\]" CHANGELOG.md; then + echo "错误: CHANGELOG.md 未找到 ${VERSION#v} 版本记录" + echo "请先更新 CHANGELOG.md" + exit 1 +fi + +# 提取版本内容测试 +NOTES=$(sed -n "/^## \[${VERSION#v}\]/,/^## \[/p" CHANGELOG.md | sed '1d;$d') +if [ -z "$NOTES" ]; then + echo "错误: 无法提取 ${VERSION#v} 版本内容" + exit 1 +fi + +# 检查标签是否已存在 +if git tag -l | grep -q "^${VERSION}$"; then + echo "错误: 标签 $VERSION 已存在" + exit 1 +fi + +# 预览 Release Notes +echo "=== Release Notes 预览 ===" +echo "$NOTES" +echo "=========================" +``` + +### 2. 发布前确认 + +**向用户展示以下信息并请求确认** + +``` +发布版本: vX.Y.Z + +检查结果: +✓ 版本号格式正确 +✓ CHANGELOG.md 包含目标版本 +✓ Release Notes 提取成功 +✓ 标签不存在 +✓ 工作区干净 + +待执行命令: +1. git tag vX.Y.Z +2. git push origin vX.Y.Z +3. gh release create vX.Y.Z --title "vX.Y.Z" --notes "..." + +确认发布? (y/n) +``` + +### 3. 子模块发布 Release + +```bash +# 1. 进入子模块目录 +cd <子模块路径> + +# 2. 执行预检查(步骤 1) + +# 3. 创建并推送标签 +git tag +git push origin + +# 4. 创建 GitHub Release +gh release create \ + --title "v" \ + --notes "$(sed -n "/^## \[${VERSION#v}\]/,/^## \[/p" CHANGELOG.md | sed '1d;$d')" \ + --repo quanttide/<仓库名> +``` + +### 4. 主仓库发布 Release + +```bash +# 1. 创建预发布版本(可选) +gh release create vX.Y.Z-rc.1 \ + --prerelease \ + --title "vX.Y.Z RC" \ + --notes "$(sed -n "/^## \[X.Y.Z\]/,/^## \[/p" CHANGELOG.md | sed '1d;$d')" + +# 2. 确认所有子模块已更新 +git submodule update --remote +git status + +# 3. 更新 CHANGELOG.md + +# 4. 提交 CHANGELOG.md +git add CHANGELOG.md && git commit -m "docs: update CHANGELOG for vX.Y.Z" + +# 5. 执行预检查(步骤 1) + +# 6. 发布前确认(步骤 2) + +# 7. 创建标签并推送 +git tag && git push origin + +# 8. 创建 GitHub Release +gh release create \ + --title "v" \ + --notes "$(sed -n "/^## \[${VERSION#v}\]/,/^## \[/p" CHANGELOG.md | sed '1d;$d')" \ + --repo quanttide/quanttide-founder + +# 9. 验证 Release +gh release view --repo quanttide/quanttide-founder +``` + +### 5. 错误处理和回滚 + +```bash +# 标签已创建但 Release 失败 +git tag -d +git push origin --delete 2>/dev/null || true + +# 恢复到发布前状态(如果有提交) +git reset --hard HEAD~1 + +# 清理预发布版本 +gh release delete vX.Y.Z-rc.1 --repo quanttide/quanttide-founder --yes +``` + +## 常见错误 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| CHANGELOG 缺少版本 | 忘记更新 CHANGELOG.md | 添加版本记录后再发布 | +| 标签已存在 | 重复发布 | 删除旧标签或使用新版本号 | +| 工作区脏 | 有未提交变更 | 提交或暂存变更后再发布 | +| Release Notes 为空 | 版本格式不匹配 | 检查 CHANGELOG 版本标题格式 | +| 子模块未更新 | 子模块有新提交 | 执行 `git submodule update --remote` | + +## 预发布检查清单 + +- [ ] 所有子模块版本已锁定 +- [ ] 通过 CI 测试 +- [ ] CHANGELOG.md 版本段已验证 +- [ ] 执行过 `npm run build` (如适用) +- [ ] 版本号格式正确 +- [ ] Release Notes 提取成功 +- [ ] 工作区干净 + +## 输出 + +### 成功时返回 + +``` +✓ Release vX.Y.Z 创建成功 + 标签: vX.Y.Z + URL: https://github.com/quanttide/quanttide-founder/releases/tag/vX.Y.Z + 提交: +``` + +### 失败时返回 + +``` +✗ Release vX.Y.Z 创建失败 + 错误码: + 原因: <错误描述> + 建议: <解决方案> +``` \ No newline at end of file diff --git a/.agents/skills/devops-review/SKILL.md b/.agents/skills/devops-review/SKILL.md new file mode 100644 index 00000000..d1316567 --- /dev/null +++ b/.agents/skills/devops-review/SKILL.md @@ -0,0 +1,229 @@ +--- +name: devops-review +description: 审查仓库状态、CHANGELOG、版本一致性等,支持发布前检查、代码审查、文档审查等多场景。 +--- + +# devops-review + +统一审查仓库状态,为多种工作流程提供前置检查。 + +## 功能 + +- 验证 CHANGELOG.md 版本连续性 +- 检查未追踪文件 +- 验证子模块状态 +- 检查标签与代码一致性 +- 验证版本号格式 + +## 使用场景 + +- 发布前验证 +- 定期健康检查 +- CI/CD 流水线检查 + +## 验证项 + +### 1. 版本号格式验证 + +```bash +validate_version_format() { + local VERSION="$1" + + if [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "✓ 版本号格式正确: $VERSION" + return 0 + else + echo "✗ 版本号格式错误: $VERSION" + echo " 预期格式: vX.Y.Z 或 vX.Y.Z-qualifier" + echo " 示例: v1.0.0, v0.4.0-alpha.1" + return 1 + fi +} +``` + +### 2. CHANGELOG 验证 + +```bash +validate_changelog() { + local VERSION="$1" + local VERSION_NUM="${VERSION#v}" # 移除 v 前缀 + + # 检查 CHANGELOG 文件存在 + if [ ! -f "CHANGELOG.md" ]; then + echo "✗ CHANGELOG.md 文件不存在" + return 1 + fi + + # 检查目标版本存在 + if ! grep -q "^## \[${VERSION_NUM}\]" CHANGELOG.md; then + echo "✗ CHANGELOG.md 未找到版本 [${VERSION_NUM}]" + echo " 已有版本:" + grep "^## \[" CHANGELOG.md | head -5 + return 1 + fi + + # 提取版本内容 + local NOTES=$(sed -n "/^## \[${VERSION_NUM}\]/,/^## \[/p" CHANGELOG.md | sed '1d;$d') + + if [ -z "$NOTES" ]; then + echo "✗ 版本 [${VERSION_NUM}] 内容为空" + return 1 + fi + + echo "✓ CHANGELOG.md 版本 [${VERSION_NUM}] 验证通过" + echo " 内容预览:" + echo "$NOTES" | head -3 + return 0 +} +``` + +### 3. 工作区状态验证 + +```bash +validate_working_directory() { + local STATUS=$(git status --porcelain) + + if [ -z "$STATUS" ]; then + echo "✓ 工作区干净" + return 0 + else + echo "✗ 工作区有未提交变更:" + echo "$STATUS" + return 1 + fi +} +``` + +### 4. 子模块状态验证 + +```bash +validate_submodules() { + # 检查是否有子模块 + if [ ! -f ".gitmodules" ]; then + echo "✓ 无子模块" + return 0 + fi + + # 检查子模块初始化状态 + local INIT_STATUS=$(git submodule status | grep -c "^-") + if [ "$INIT_STATUS" -gt 0 ]; then + echo "✗ 有 $INIT_STATUS 个子模块未初始化" + git submodule status | grep "^-" + return 1 + fi + + # 检查子模块更新状态 + local UPDATE_STATUS=$(git submodule status | grep -c "^+") + if [ "$UPDATE_STATUS" -gt 0 ]; then + echo "⚠ 有 $UPDATE_STATUS 个子模块有新提交" + git submodule status | grep "^+" + echo " 建议: git submodule update --remote" + fi + + echo "✓ 子模块状态验证通过" + return 0 +} +``` + +### 5. 标签验证 + +```bash +validate_tag() { + local VERSION="$1" + + if git tag -l | grep -q "^${VERSION}$"; then + echo "✗ 标签 $VERSION 已存在" + echo " 现有标签:" + git tag -l | grep "^v" | tail -5 + return 1 + else + echo "✓ 标签 $VERSION 不存在,可以创建" + return 0 + fi +} +``` + +### 6. 远程仓库验证 + +```bash +validate_remote() { + local REMOTE="$1" + + if ! git remote | grep -q "^${REMOTE}$"; then + echo "✗ 远程仓库 '$REMOTE' 不存在" + git remote -v + return 1 + fi + + # 检查远程连接 + if ! git ls-remote "$REMOTE" &>/dev/null; then + echo "✗ 无法连接到远程仓库 '$REMOTE'" + return 1 + fi + + echo "✓ 远程仓库 '$REMOTE' 验证通过" + return 0 +} +``` + +## 完整验证流程 + +```bash +validate_release() { + local VERSION="$1" + local REMOTE="${2:-origin}" + + echo "=== 发布前验证: $VERSION ===" + echo + + local ERRORS=0 + + # 1. 版本号格式 + validate_version_format "$VERSION" || ((ERRORS++)) + + # 2. CHANGELOG + validate_changelog "$VERSION" || ((ERRORS++)) + + # 3. 工作区状态 + validate_working_directory || ((ERRORS++)) + + # 4. 子模块状态 + validate_submodules || ((ERRORS++)) + + # 5. 标签 + validate_tag "$VERSION" || ((ERRORS++)) + + # 6. 远程仓库 + validate_remote "$REMOTE" || ((ERRORS++)) + + echo + echo "=== 验证结果 ===" + + if [ "$ERRORS" -eq 0 ]; then + echo "✓ 所有验证通过,可以发布" + return 0 + else + echo "✗ 发现 $ERRORS 个错误,请修复后再发布" + return 1 + fi +} + +# 使用示例 +validate_release "v0.4.0" +``` + +## 集成到 devops-release + +在 devops-release 的预检查步骤中调用: + +```bash +# 在 .agents/skills/devops-release/SKILL.md 中 +# 步骤 1. 预检查 + +# 调用 devops-review +.agents/skills/devops-review/SKILL.md validate_release "$VERSION" +if [ $? -ne 0 ]; then + echo "发布前审查失败,请修复后再试" + exit 1 +fi +``` \ No newline at end of file diff --git a/.agents/skills/docs-deploy/SKILL.md b/.agents/skills/docs-deploy/SKILL.md new file mode 100644 index 00000000..97466bde --- /dev/null +++ b/.agents/skills/docs-deploy/SKILL.md @@ -0,0 +1,127 @@ +--- +name: docs-deploy +description: 使用 MyST Markdown 构建文档站并部署到 GitHub Pages。覆盖主仓库和子仓库两种场景。 +--- + +# docs-deploy + +使用 MyST Markdown 构建文档站并部署到 GitHub Pages。 + +## 规则 + +- 文档源码在 `docs/` 目录,MyST 配置为 `docs/myst.yml` +- GitHub Pages 使用 workflow 模式(`build_type=workflow`),非 branch 模式 +- 构建产物输出到 `docs/_build/html`,artifact 上传此目录 +- 站点地址为 `https://quanttide.github.io/<仓库名>/` +- 主仓库与子仓库配置方式相同,但主仓库 checkout 时不可拉子模块 + +## 工作流 + +### 1. 配置 MyST + +创建 `docs/myst.yml`: + +```yaml +version: 1 +project: + title: <站点标题> + description: <站点描述> + toc: + - file: index.md + - title: <分组标题> + children: + - file: <相对路径> +site: + template: book-theme +``` + +### 2. 配置 gitignore + +创建 `docs/.gitignore`,内容为 `_build/`。 + +### 3. 创建 GitHub Actions 工作流 + +创建 `.github/workflows/deploy-docs.yml`: + +```yaml +name: Deploy docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - docs/** + - .github/workflows/deploy-docs.yml + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install mystmd + - run: myst build --html + working-directory: docs + env: + BASE_URL: /<仓库名>/ + - run: cp docs/_build/html/index.html docs/_build/html/404.html + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v4 +``` + +### 4. 启用 GitHub Pages + +**必须在推送之前执行**,否则第一次 deploy workflow 触发时 Pages 尚未开启,部署会失败且浪费一次运行。 + +```bash +gh api repos///pages -X POST -f build_type=workflow +``` + +### 5. 提交推送 + +两段式提交(仅子仓库需执行此步骤): +1. 子仓库内 `git push` +2. 主仓库更新子模块引用并推送 + +### 6. 验证 + +```bash +# 确认 Actions 运行成功 +gh run list -L1 --workflow deploy-docs.yml --json name,status,conclusion + +# 查看 deploy 日志确认 "Reported success!" +gh run view --log 2>&1 | grep "Reported success" +``` + +## 常见错误 + +| 错误 | 原因 | 解决 | +|------|------|------| +| Artifact path 指向 `_build/` 而非 `_build/html` | 构建产物的实际站点文件在 `html/` 子目录 | 改为 `path: docs/_build/html` | +| `BASE_URL` 未设置 | 站点从子路径访问时 CSS/JS 路径错误 | 设为 `/<仓库名>/` | +| SPA 路由 404 | Remix SPA 的客户端路由无 fallback | `cp index.html 404.html` | +| checkout 拉子模块失败 | 主仓库子模块多且部分不可访问 | 移除 `submodules: recursive` | +| 本机 curl 返回 000 | 网络环境阻断 GitHub Pages | Actions 日志确认 `Reported success!` 即可 | +| 首次 push 后 deploy 失败 | Pages 未在推送前启用 | 先 `gh api .../pages -X POST`,再 trigger workflow | + +## 经验记录 + +- qtcloud-hr:首个试点,建立了完整模板 +- qtcloud-asset:验证模板可复用,BASE_URL 改为 `/qtcloud-asset/` +- quanttide-platform:主仓库首次部署,踩坑 recursive submodules +- qtcloud-product / qtcloud-write:本机 myst build 因模板下载超时失败,直接推至 GitHub Actions 验证通过。教训:本机网络不稳定时无需死磕本地构建,配置推上去让 Actions 跑,日志确认 `Reported success!` 即可。 diff --git a/.agents/skills/docs-format/SKILL.md b/.agents/skills/docs-format/SKILL.md new file mode 100644 index 00000000..35c5aef5 --- /dev/null +++ b/.agents/skills/docs-format/SKILL.md @@ -0,0 +1,136 @@ +--- +name: docs-format +description: 操作Markdown文档时使用。文档格式技能,遵循量潮科技文档格式标准,用于生成或检查规范文档。 +--- + +# 文档格式技能 + +遵循 [量潮科技文档格式标准](https://github.com/quanttide/quanttide-specification-of-business-entity/blob/v0.1.1/docs/format.md) + +## 写作原则 + +- **删**:删除不必要的格式元素,优先用段落和标题 +- **简**:能用列表就不表格,能用文字就不列表 +- **少**:全文格式元素(分隔线、表格、加粗)尽量少 +- **一**:同一概念全程使用相同名称 + +## 标题规范 + +- 最多使用三级标题(`#` / `##` / `###`) +- 避免在标题中使用标点符号 +- 标题应简洁,明确概括内容 +- 一级标题仅文档标题使用一次 + +## 分隔线规范 + +分隔线(`---`)用于划分文档主要部分: + +- 优先用空行+标题区分章节 +- 分隔线仅用于重要划分点 +- 全文最多使用 3 处 + +## 列表规范 + +无序列表(`-`)适用于: +- 并列的多个要点 +- 不分先后顺序的内容 +- 短小的条目 + +有序列表(`1.`)适用于: +- 有明确顺序的步骤 +- 需要编号的操作流程 +- 排名或优先级 + +避免场景: +- 列表项过长(超过两行) +- 嵌套超过 2 级 +- 滥用列表代替段落 + +嵌套列表: +- 缩进使用 2 个空格 +- 嵌套层级不超过 2 级 + +## 代码块 + +必须标注代码语言类型: + +```bash +git status +``` + +```python +def hello(): + print("Hello") +``` + +行内代码使用反引号:`variable`、`function()`、`file.md` + +## 表格规范 + +表格用于呈现多维度需对比的数据。 + +应使用表格的场景: +- 需要横向对比多个项目 +- 数据具有明确的列属性 +- 信息结构化为行记录 + +应避免的场景: +- 仅是简单的名词-定义对应(用列表代替) +- 两列且无对比需求 +- 单元格内容过长 + +基本格式: + +| 列1 | 列2 | 列3 | +|:----|:---:|----:| +| 左对齐 | 居中 | 右对齐 | + +规范: +- 表头加粗 +- 列对齐使用冒号(`:--`、`:--:`、`--:`) +- 内容简洁,避免单元格过长 + +## 链接规范 + +外部链接:`[链接文本](https://example.com)` + +内部链接:使用相对路径 `[文档](./docs/guide.md)` + +## 加粗规范 + +加粗用于强调关键词,避免过度使用。 + +应使用加粗的场景: +- 首次定义关键术语 +- 强调重要的操作或警告 +- 引导注意力到关键信息 + +应避免的场景: +- 标记所有术语名词 +- 连续多个加粗 +- 在列表项内部使用 + +块引用:`> 使用块引用标注重要提示、警告或引用内容` + +## 引号规范 + +中文文档使用中文引号: +- 直接引用:「这是引用内容」或「这是引用内容」 +- 术语引用:「变量名」 + +应使用引号的场景: +- 直接引用他人的话语或文本 +- 引用特定术语或概念名称(首次定义时) +- 强调某个词汇的特殊含义或用法 +- 标注按钮、菜单项等界面元素名称 + +应避免的场景: +- 包裹所有术语名词 +- 用于普通词汇的强调(用加粗代替) +- 在列表项或标题中频繁使用 +- 包裹整个句子或段落作为"引用" + +替代方案: +- 普通强调使用加粗:**关键信息** +- 术语使用行内代码:`variable` +- 大段引用使用块引用:> 引用内容 diff --git a/.agents/skills/product-studio/SKILL.md b/.agents/skills/product-studio/SKILL.md new file mode 100644 index 00000000..7a71287f --- /dev/null +++ b/.agents/skills/product-studio/SKILL.md @@ -0,0 +1,109 @@ +--- +name: product-studio +description: Flutter Studio 客户端开发流程。从需求到发布,覆盖项目初始化、数据模型、UI 开发、构建、提交的完整工作流。 +--- + +# product-studio + +Flutter Studio 客户端开发流程。 + +## 参考技能 + +- devops-commit: 提交代码变更 + +## 工作流 + +### 1. 初始化 Flutter 项目 + +```bash +flutter create --project-name --org com.quanttide --platforms android,ios,web,macos,linux +``` + +必须的参数: +- `--project-name`: 包名,也是 Linux 产物名(如 `qtconsult_studio`) +- `--org`: Android 包名前缀 +- `--platforms`: 目标平台列表 + +### 2. 配置 pubspec.yaml + +```yaml +name: qtconsult_studio +description: <描述> + +dependencies: + flutter: + sdk: flutter + provider: ^6.1.5 + +flutter: + assets: + - assets/.json +``` + +必须添加的依赖: +- `provider`:状态管理 +- `assets/`:数据源 JSON 文件 + +### 3. 搭建目录结构 + +``` +lib/ + main.dart # 入口 + Provider 初始化 + models/ # 数据模型 + services/ # JSON 加载器 + ChangeNotifier + screens/ # 页面级组件 + widgets/ # 可复用 UI 组件 +assets/ + .json # 模拟数据 +``` + +### 4. 设计数据模型 + +每条模型必须包含: +- `const` 构造函数 +- `factory fromJson(Map)` 工厂方法 +- `copyWith` 方法(可选) +- 关联的枚举类型 + +### 5. 修改应用显示名称 + +Flutter 默认用项目目录名作为应用名称,需手动修改以下文件: + +| 平台 | 文件 | 修改内容 | +|------|------|---------| +| Linux | `linux/runner/my_application.cc` | `gtk_header_bar_set_title` 和 `gtk_window_set_title` | +| Android | `android/app/src/main/AndroidManifest.xml` | `android:label` | +| iOS | `ios/Runner/Info.plist` | `CFBundleDisplayName` 和 `CFBundleName` | + +### 6. 验证构建 + +```bash +cd src/ +dart analyze lib/ +flutter build linux +``` + +必须满足: +- `dart analyze lib/` 零报错 +- `flutter build linux` 构建成功 + +### 7. 提交流程 + +遵循 devops-commit 规范,提交类型: +- `feat`: 新功能 +- `fix`: 修复 +- `chore`: 构建/配置变更 +- `docs`: 文档 + +子模块提交流程: + +```bash +# 子模块内 +cd apps/ +git add -A && git commit -m "feat: <描述>" && git push + +# 主仓库 +cd ../.. +git add apps/ +git commit -m "chore: update submodule (<描述>)" && git push +``` diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f26e312f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +assets/videos/** filter=lfs diff=lfs merge=lfs -text +assets/videos/studio.mp4 filter=lfs diff=lfs merge=lfs -text diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..45ada516 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,16 @@ +#!/bin/bash +# pre-commit hook: quick check for studio +# Full test suite runs in CI (.github/workflows/studio.yml) + +STUDIO="src/studio" +if ! git diff --cached --name-only | grep -q "^$STUDIO/"; then + exit 0 +fi + +(cd "$STUDIO" && dart analyze lib/ test/) +rc=$? +if [ $rc -ne 0 ]; then + echo "✗ dart analyze 失败,提交终止" + exit 1 +fi +echo "✓ dart analyze 通过" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 00000000..b0a977d9 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,48 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - docs/** + - docs/myst.yml + - .github/workflows/deploy-docs.yml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +env: + BASE_URL: /${{ github.event.repository.name }} + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install MyST + run: npm install -g mystmd + - name: Build HTML + run: myst build --html + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..0fbabaf5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy to OSS + +on: + release: + types: [published] + +env: + FLUTTER_VERSION: "3.41.9" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + + - name: Install dependencies + working-directory: src/studio + run: flutter pub get + + - name: Build Web + working-directory: src/studio + run: flutter build web --release + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: flutter-build + path: src/studio/build/web + retention-days: 3 + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'release' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: flutter-build + path: build/web + + - name: Set env vars + run: | + echo "ALIYUN_ACCESS_KEY_ID=${{ secrets.ALIYUN_ACCESS_KEY_ID }}" >> $GITHUB_ENV + echo "ALIYUN_ACCESS_KEY_SECRET=${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}" >> $GITHUB_ENV + + - name: Upload to OSS + run: | + pip install oss2 + python scripts/upload_oss.py diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml new file mode 100644 index 00000000..71cbb22d --- /dev/null +++ b/.github/workflows/studio.yml @@ -0,0 +1,25 @@ +name: studio + +on: + push: + paths: + - 'src/studio/**' + pull_request: + paths: + - 'src/studio/**' + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/studio + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.x' + channel: stable + - run: flutter pub get + - run: dart analyze lib/ test/ + - run: flutter test diff --git a/.gitignore b/.gitignore index dd0020cc..5455af8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,53 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.venv/ +venv/ +ENV/ + +# Data +data/ +.env + +# IDE .idea/ +.vscode/ +*.swp +*.swo -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Web related -lib/generated_plugin_registrant.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json +# OS +.DS_Store +Thumbs.db -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# Database & backup +*.db +*.bak -# 环境变量 -environment_config.dart \ No newline at end of file +# Flutter +.gradle/ +*.iml +.metadata +src/studio/build/ +src/studio/.dart_tool/ + +# Terraform +.terraform/ +terraform/terraform.tfstate +terraform/terraform.tfstate.backup diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b7028df2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Agent Guidelines for qtadmin + +> **必读:** 先读 `CONTRIBUTING.md`、`README.md`、`ROADMAP.md`。 + +## Project Overview + +qtadmin 是 QuantTide 的第二大脑平台。当前重心在 Flutter 客户端(`src/studio/`),后端(`src/provider/`)处于维护状态。 + +## Studio + +重心所在。所有开发原则、经验教训、架构约定见 `src/studio/AGENTS.md`。代理在 studio 下工作时必须先读。 + +```bash +cd src/studio +flutter run -d linux +flutter run -d chrome +dart analyze lib/ test/ +flutter test +dart run build_runner build # freezed codegen +``` + +## Provider(维护态) + +```bash +cd src/provider +pdm run uvicorn app:app --reload +pytest +``` + +## 版本约定 + +- `v0.0.x` — 探索验证阶段,技术债清理、架构验证 +- `v0.1.0` 起 — 进入上线推进阶段,标记探索期结束 + +主仓库与 studio 子标签版本号同步,升则同升。 + +## Documentation + +- `docs/dev/` — 开发文档 +- `docs/ops/` — 运维文档 +- `src/studio/README.md` — studio 流程信息 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..92078b78 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,189 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/). + +## [0.1.0] - 2026-05-09 + +### Studio + +独立发布 `v0.1.0`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 + +## [0.0.9] - 2026-05-09 + +### Studio + +独立发布 `v0.0.7`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 + +## [0.0.8] - 2026-05-08 + +### Studio + +独立发布 `v0.0.6`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 + +## [0.0.7] - 2026-05-08 + +### Added + +- `docs/dev/pmd.md`:问题管理文档(业务问题 + 技术问题双维度记录) +- `.agents/skills/`:技能系统(从 quanttide-platform 同步) + +### Changed + +- `src/studio/` 租户(Tenant) → Workspace工作空间(Workspace) 全量重命名 + - `TenantType` → `WorkspaceType`,`TenantInfo` → `WorkspaceInfo`,`TenantSwitcher` → `WorkspaceSwitcher` + - 所有相关字段/参数/变量同步更新 +- `src/studio/` 文档、Dart 代码标识符、JSON fixture 键全量替换 + +### Fixed + +- `src/studio/` 修复数据加载完成前侧边栏空 `workspaces` 列表导致的 `RangeError` +- `src/studio/` 修复 web 平台 fixture 加载(改用 HTTP asset loader) +- `src/studio/` 修复 Aliyun OSS 部署配置 + +### Docs + +- `ROADMAP.md`:项目路线规划文档 + +### Studio + +独立发布 `v0.0.6`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 + +## [0.0.6] - 2026-05-08 + +### Added + +- `docs/add/qtclass.md`:量潮课堂架构设计文档(课程域/组织域分离) +- `docs/drd/dashboard.md`:仪表盘数据模型 schema +- `docs/drd/qtclass.md`:量潮课堂数据模型 schema +- `docs/drd/thinking.md`:思考页面数据模型 schema + +### Changed + +- `src/studio/` 全景图→仪表盘全面重命名(`panorama` → `dashboard`) + - 侧边栏导航项"全景图"→"仪表盘" + - 数据模型 `PanoramaData` → `DashboardData`,路由类型 `panorama` → `dashboard` + - 所有 import、变量名、fixture 文件同步更新 +- `src/studio/` 量潮课堂从通用业务详情页改为独立页面(`pageType: classroom`) + - 新增 `QtClassScreen`:四个组成部分(校企合作/实训基地/内部教学/一对一)卡片展示 +- `src/studio/` 思考页面数据抽取为 fixture 驱动 + - 新增 `ThinkingData` 模型 + `thinking.json` fixture + - `ThinkingScreen` 从硬编码改为接收数据参数 +- `src/studio/` 版本发布 v0.0.5 +- `docs/drd/metadata.md`:路由表更新(`dashboard`/`classroom` 新增,`thinking` 数据源补充) + +### Studio + +独立发布 `v0.0.5`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 + +## [0.0.5] - 2026-05-08 + +### Added + +- `assets/fixtures/metadata.json`:根注册表(Workspace工作空间清单 + 段定义) +- `NavSidebar` 独立组件,封装侧边栏全部布局逻辑 +- `docs/drd/` 数据规范目录:metadata.json + qtconsult.json schema +- `docs/dev/README.md`:主仓库开发文档边界说明 + +### Changed + +- `src/studio/` 导航重构: + - 根 metadata + 每Workspace工作空间 metadata 两层分离,分隔线规则从 Dart 代码移到 JSON + - `_NavItem`/`_NavIcon`/`_WorkspaceSwitcher` 从 `main.dart` 私有类提取为公开组件 + - `lib/widgets/` → `lib/views/` + - `_buildSidebar` 替换为 `NavSidebar`,新增Workspace工作空间无需改 Dart 代码 +- `src/studio/CHANGELOG.md`:独立维护 Studio 版本日志 +- 文档结构重组: + - `docs/dev/studio.md` → `src/studio/doc/index.md`(Studio 实现文档归入子模块) + - `docs/add/qtconsult.md` → `src/studio/doc/screens/qtconsult.md`(降级为屏幕实现) + - `docs/add/multi-workspace.md` 删除 + - `docs/drd/` 新增数据规范,与实现文档分离 + - `docs/myst.yml` 同步更新目录结构 + + +## [0.0.4] - 2026-05-06 + +### Added + +- `docs/`: 咨询业务线全套文档 + - BRD:信息-策略断层业务需求说明书 + - PRD:双栏联动设计 + 三层交互原则 + - IXD:信息看板+策略看板页面布局 + - ADD:咨询模块数据模型与架构设计文档 +- `src/studio/`: 量潮咨询详情页(QtConsultScreen) + - 双栏联动面板:信息看板(发现/沟通) + 策略看板(诉求/策略/决策链路) + - 发现→策略强制联动:高风险发现自动追加审视记录 + - 完整 CRUD 交互(添加/确认/驳回/删除发现,标记审视) + - 数据抽离至 `assets/qtconsult.json` + - ADD 架构设计文档 +- `examples/prototype/qtconsult.html`:咨询原型(本地存储 + 完整交互) + +### Changed + +- `src/studio/` 导航重构:`_workspaces` 改为实例字段,支持动态页面加载 +- `src/studio/pubspec.yaml` 注册 `qtconsult.json` asset + +### Studio + +独立发布 `v0.0.3`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 + +## [0.0.3] - 2026-05-06 + +### Added + +- `src/studio/`: 多Workspace工作空间架构 + - 量潮创始人:全景图 + 思考(认知演进报告)+ 写作(占位) + - 量潮科技:全景图 + 量潮数据/课堂/咨询/云 + - Workspace工作空间切换器(PopupMenuButton),支持一键切换 + - 思考页面(ThinkingScreen):认知建构与思维演进分析报告 +- `examples/default/`:日志文本分析工具及报告 +- `scripts/record-studio-linux.sh`:自动录屏脚本(ffmpeg + xdotool) +- `assets/videos/studio.mp4`:客户端演示视频(Git LFS 管理) +- `.gitattributes`:Git LFS 跟踪 `assets/videos/**` + +### Changed + +- Git LFS 管理大文件 +- Flutter 依赖升级 + +## [0.0.2] - 2026-05-06 + +### Added + +- `src/studio/`: 全景图今日看板(Flutter 实现) + - 全景图主页面(业务线决策卡片 + 职能线指标卡片) + - 业务线详情页(量潮数据/课堂/咨询/云) + - 决策卡片交互(批准/驳回/附条件) + - 响应式布局(桌面多列 / 移动端单列+折叠) + - 数据抽离至 `assets/panorama.json`,支持热更新 +- `scripts/run-studio-linux.sh`:Linux 编译运行脚本 + +### Changed + +- 全平台应用名统一为 `qtadmin_studio` / 量潮管理后台 +- Flutter 依赖升级至最新兼容版本 +- 导航栏重构为自定义侧边栏(全景图 + 4 业务线) + +## [0.0.1] - 2026-04-30 + +### Added + +- `src/provider/`: 基于 FastAPI + uv 的空后端项目骨架 +- `tests/cli/`: CLI 集成测试目录 + +### Removed + +- `src/provider/` 历史代码:薪资模块、员工 CRUD、数据库、旧测试 +- `src/studio/lib/screens/` 和 `src/studio/lib/models/`(旧 Flutter UI) +- `examples/` 和 `tests/` 中的零散实验脚本 +- 根目录 `pyproject.toml`(CLI 由 `src/cli/pyproject.toml` 独立管理) +- `src/provider/` 的 PDM 构建配置,替换为 uv + +### Moved + +- 薪资计算代码 → `qtcloud-hr/examples/salary/` +- 资产契约 UI 代码 → `qtcloud-asset/` +- `src/cli/integrated_tests/` → `tests/cli/` + +### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d6f1e8a1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# CONTRIBUTING + +## 保密规范 + +**禁止在公开文档、代码、示例中泄漏客户敏感信息**,包括但不限于:客户/公司名称、业务数据、真实案例内容。示例用通用描述(如"文件1"、"数据清洗")。 + +## 运行命令 + +### Provider +```bash +cd src/provider +pdm install +pdm run uvicorn app:app --reload +pytest +``` + +### Studio +```bash +cd src/studio +flutter run -d linux +flutter run -d chrome +dart analyze lib/ +``` + +## 代码规范 + +### Python + +| 约定 | 规则 | +|------|------| +| 版本 | 3.10+ | +| 命名 | `snake_case` 函数/变量,`PascalCase` 类 | +| 类型标注 | 全部参数和返回值必须标注 | +| 导入顺序 | stdlib → third-party → local,每组空行分隔 | +| 文档 | 中文 docstring | +| 行宽 | 100 字符以内 | + +### Dart / Flutter + +| 约定 | 规则 | +|------|------| +| 命名 | `camelCase` 变量/函数,`PascalCase` 类/Widget | +| 导入顺序 | Dart SDK → Flutter → third-party → local | +| Widget | `const` 优先,`StatefulWidget` 只在需要状态时用 | +| 文件 | `snake_case.dart` | + +## Git 规范 + +使用 `cz commit`(commitizen)生成 Conventional Commits。 + +| 类型 | 说明 | +|------|------| +| `feat` | 新功能 | +| `fix` | 修复 bug | +| `refactor` | 代码重构 | +| `docs` | 文档更新 | +| `test` | 测试相关 | +| `chore` | 构建/工具/配置 | + +## 发布规范 + +monorepo 标签格式:`{项目}/v{版本}`,如 `studio/v0.1.0`。 + +流程:更新版本号 → 更新 CHANGELOG → commit → tag → push → GitHub Release。 + +## Pull Request + +1. 确保通过 lint:`dart analyze lib/`(studio)或 `ruff check .`(provider) +2. 确保测试通过:`pytest`(provider) +3. 用 `cz commit` 提交 +4. PR 标题概括变更,说明附上动机和影响范围 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index cbc8128f..9f430c02 100644 --- a/README.md +++ b/README.md @@ -1,3 +1 @@ -# 量潮管理后台客户端 - - +# 量潮管理后台 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..808d004f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,4 @@ +# ROADMAP + +- [ ] 迁移量潮咨询标准到本应用。 +- [ ] 迁移本应用标准到量潮课堂。 diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 00000000..fdcf3f75 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,26 @@ +# STATUS + +## Recent commits + +- `1eea498` myst.yml 仅保留 user-guide 目录,移除其他 toc 条目 +- `59c6893` 添加资产职能用户手册(基于 clig.dev 评审完善) +- `09c50f0` 移除已废弃的产品文档技能(BRD/DRD/PRD) +- `c3ac35e` 添加 Apache 2.0 协议 +- `45fe194` record architecture decisions belong to human principle + +## Branches + +- `main` — 当前活跃分支 +- `gh-pages` — GitHub Pages 部署分支 + +## Submodules + +无子模块。 + +## docs/ 状态 + +**myst.yml 当前 toc 仅包含:** +- `user-guide/asset.md` + +**遗留目录(文件仍在磁盘,但已从 myst.yml 移除):** +- `brd/`, `prd/`, `drd/`, `ixd/`, `add/`, `dev/`, `ops/` diff --git a/assets/fixtures/.gitkeep b/assets/fixtures/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/assets/fixtures/company/dashboard.json b/assets/fixtures/company/dashboard.json new file mode 100644 index 00000000..d654d58e --- /dev/null +++ b/assets/fixtures/company/dashboard.json @@ -0,0 +1,130 @@ +{ + "businessUnits": [ + { + "name": "量潮数据", + "tag": "主营", + "isPrimary": true, + "decisions": [ + { + "fromPerson": "陈小明", + "deadline": "本周内回复", + "title": "华为数据清洗 · 接不接?", + "context": "回头客 ¥12,000,10周。产能刚好够,但接了教育类要等一个月。", + "teamAdvice": "小明倾向:接,维持老客户", + "isUrgent": false, + "actions": [ + { "label": "批准", "isPrimary": true }, + { "label": "驳回", "isPrimary": false }, + { "label": "附条件", "isPrimary": false } + ] + }, + { + "fromPerson": "李四维", + "deadline": "下周一前", + "title": "牛津项目 · 新增分析维度", + "context": "合同外需求。加则多2周,不加可能影响海外口碑。", + "teamAdvice": "四维建议:加,牛津是桥头堡", + "isUrgent": false, + "actions": [ + { "label": "同意加需求", "isPrimary": true }, + { "label": "婉拒", "isPrimary": false } + ] + } + ] + }, + { + "name": "量潮课堂", + "tag": "主营", + "isPrimary": true, + "decisions": [ + { + "fromPerson": "王老师", + "deadline": "今日需定", + "title": "杭电Python实训 · 已超期2周", + "context": "客户在催。加人赶工还是谈延期?", + "teamAdvice": "王老师建议:谈延期", + "isUrgent": true, + "actions": [ + { "label": "同意延期", "isPrimary": true }, + { "label": "加人赶工", "isPrimary": false } + ] + } + ] + }, + { + "name": "量潮咨询", + "tag": "主营", + "isPrimary": true, + "screenType": "consulting", + "consultSource": "customer", + "decisions": [ + { + "fromPerson": "赵一凡", + "deadline": "本周五前", + "title": "某制造企业数字化评估 · 报价方案", + "context": "新客户,初步需求已明确。需提交评估报价,预计4周交付。", + "teamAdvice": "一凡建议:接,开拓制造业标杆", + "isUrgent": false, + "actions": [ + { "label": "批准", "isPrimary": true }, + { "label": "调整报价", "isPrimary": false }, + { "label": "婉拒", "isPrimary": false } + ] + } + ] + }, + { + "name": "量潮云", + "tag": "孵化中", + "isPrimary": false, + "decisions": [], + "emptyMessage": "暂无待决策事项\n市场调研进行中" + } + ], + "functionCards": [ + { + "name": "人力资源", + "metrics": [ + { "label": "团队", "value": "8人" }, + { "label": "出勤", "value": "全员" }, + { "label": "待审批", "value": "0" } + ], + "trend": { "text": "无异常", "direction": "flat" } + }, + { + "name": "财务管理", + "metrics": [ + { "label": "本月回款", "value": "¥84k/120k" }, + { "label": "现金流", "value": "健康" } + ], + "trend": { "text": "无预警", "direction": "flat" } + }, + { + "name": "组织管理", + "isWarning": true, + "metrics": [ + { "label": "决策委托率", "value": "42%" }, + { "label": "标准化率", "value": "60%" }, + { "label": "去中心化度", "value": "40%" } + ], + "trend": { "text": "↓5% 比上月", "direction": "down" }, + "warning": "连续2月下降" + }, + { + "name": "战略管理", + "metrics": [ + { "label": "季度OKR", "value": "推进中" }, + { "label": "量潮云", "value": "报告下周出" } + ], + "trend": { "text": "无阻塞", "direction": "flat" } + }, + { + "name": "新媒体", + "metrics": [ + { "label": "公众号", "value": "按时" }, + { "label": "知乎", "value": "3篇/周" } + ], + "trend": { "text": "稳定", "direction": "flat" } + } + ] +} diff --git a/assets/fixtures/company/metadata.json b/assets/fixtures/company/metadata.json new file mode 100644 index 00000000..16c190fc --- /dev/null +++ b/assets/fixtures/company/metadata.json @@ -0,0 +1,16 @@ +{ + "sections": [ + { + "id": "dashboard", + "items": ["dashboard"] + }, + { + "id": "business", + "items": ["data", "classroom", "consulting", "cloud"] + }, + { + "id": "function", + "items": ["hr", "finance", "org", "strategy", "media"] + } + ] +} diff --git a/assets/fixtures/company/org.json b/assets/fixtures/company/org.json new file mode 100644 index 00000000..10e370bd --- /dev/null +++ b/assets/fixtures/company/org.json @@ -0,0 +1,25 @@ +{ + "institutions": [ + { "id": "shareholders", "name": "股东代表大会", "parentId": "", "level": 0, "status": "normal", "expectedFrequency": "每季一次", "pendingProposalCount": 0, "lastMeetingDate": "15天前", "nextMeetingDate": "75天后" }, + { "id": "partner", "name": "合伙人委员会", "parentId": "shareholders", "level": 1, "status": "normal", "expectedFrequency": "每月一次", "pendingProposalCount": 0, "lastMeetingDate": "3天前", "nextMeetingDate": "28天后" }, + { "id": "assembly", "name": "公司代表大会", "parentId": "", "level": 0, "status": "normal", "expectedFrequency": "每月一次", "pendingProposalCount": 1, "lastMeetingDate": "5天前", "nextMeetingDate": "25天后" }, + { "id": "secretary", "name": "书记处", "parentId": "assembly", "level": 1, "status": "normal", "expectedFrequency": "每周一次", "pendingProposalCount": 1, "lastMeetingDate": "2天前", "nextMeetingDate": "5天后" }, + { "id": "exec", "name": "执行委员会", "parentId": "assembly", "level": 1, "status": "warning", "expectedFrequency": "每周一次", "pendingProposalCount": 2, "lastMeetingDate": "7天前", "nextMeetingDate": "明天" }, + { "id": "tech", "name": "技术委员会", "parentId": "assembly", "level": 1, "status": "overdue", "expectedFrequency": "每周一次", "pendingProposalCount": 3, "lastMeetingDate": "12天前", "nextMeetingDate": "逾期" } + ], + "representatives": [ + { "id": "p1", "name": "张三", "institutionIds": ["secretary", "exec"], "rank": "M1", "term": "2026Q1-Q2", "attendanceRate": 100, "proposalCount": 5, "voteRate": 100, "objectionCount": 1, "tier": "green", "recentVotes": [ + { "id": "m1", "institutionId": "secretary", "date": "2026-05-06", "title": "预算审批会议", "agendaItems": ["Q3预算审批"], "attendeeCount": 9, "totalMemberCount": 10 }, + { "id": "m2", "institutionId": "secretary", "date": "2026-04-29", "title": "周例会", "agendaItems": ["进度同步"], "attendeeCount": 10, "totalMemberCount": 10 } + ] }, + { "id": "p2", "name": "李四", "institutionIds": ["exec"], "rank": "M2", "term": "2026Q1-Q2", "attendanceRate": 60, "proposalCount": 2, "voteRate": 70, "objectionCount": 0, "tier": "yellow", "recentVotes": [] } + ], + "ranks": [ + { "name": "专业序列", "isManagement": false, "headCount": 5 }, + { "name": "M1", "isManagement": true, "headCount": 2 }, + { "name": "M2", "isManagement": true, "headCount": 1 } + ], + "promotions": [ + { "id": "pr1", "personName": "王五", "fromRank": "专业序列", "toRank": "M1", "date": "2026-04-01", "isCrossTrack": true } + ] +} diff --git a/assets/fixtures/company/qtclass.json b/assets/fixtures/company/qtclass.json new file mode 100644 index 00000000..fdc3c160 --- /dev/null +++ b/assets/fixtures/company/qtclass.json @@ -0,0 +1,58 @@ +{ + "components": [ + { + "type": "schoolEnterprise", + "name": "校企合作", + "description": "与高校合作开展人才培养、课程共建、实习基地等项目", + "status": "进行中", + "studentCount": 128, + "projectCount": 6, + "deadline": "2026-Q2", + "highlights": [ + "杭电Python实训项目进行中", + "浙大数据科学课程共建已签约", + "3所新高校合作洽谈中" + ] + }, + { + "type": "trainingBase", + "name": "实训基地", + "description": "提供实战化技能训练,面向企业和个人开放", + "status": "运营中", + "studentCount": 256, + "projectCount": 12, + "deadline": "持续运营", + "highlights": [ + "数据分析实训营第4期即将开营", + "企业定制实训服务已交付3家", + "线上实训平台内测中" + ] + }, + { + "type": "internalTeaching", + "name": "内部教学", + "description": "公司内部知识分享、技术培训、新人带教体系", + "status": "常态化", + "studentCount": 24, + "projectCount": 4, + "highlights": [ + "每周五技术分享会持续进行", + "新人入职培训体系已迭代v3", + "内部知识库累计200+篇文章" + ] + }, + { + "type": "oneOnOne", + "name": "一对一", + "description": "个性化辅导服务,针对特定技能或项目需求", + "status": "可预约", + "studentCount": 18, + "projectCount": 8, + "highlights": [ + "导师资源池:8名导师", + "覆盖Python/数据分析/机器学习方向", + "学员满意度评分4.8/5.0" + ] + } + ] +} diff --git a/assets/fixtures/company/qtconsult.json b/assets/fixtures/company/qtconsult.json new file mode 100644 index 00000000..675b7834 --- /dev/null +++ b/assets/fixtures/company/qtconsult.json @@ -0,0 +1,97 @@ +{ + "workspace": "internal", + "projectName": "量潮科技自我诊断", + "phase": "持续观察", + "industry": "IT咨询 · 技术服务", + "scale": "核心团队", + "maturity": "数字化成熟度 L3", + "strategyGoal": "建立稳定的高客单价项目获取机制,提升交付效率与团队承载力", + "strategyInsight": "判断:团队能力已溢出当前项目体量,但不敢接大单。瓶颈不在交付能力,而在预期管理和销售自信。", + "strategySteps": [ + "第一步:用当前接近谈成的大单验证交付能力,建立标杆案例", + "第二步:重构项目管理框架(以看板为原方法统一瀑布与敏捷),拉升团队承载力", + "第三步:建立「内观-外观」双平台机制,让自我观察成为制度化能力" + ], + "riskNote": "创始人精力分散风险:同时在跑平台建设、项目交付、商务谈判三条线,需要秘书处分担协调工作", + "discoveries": [ + { + "id": "d1", + "text": "团队产能利用率不足60%,但每个人感觉都在满负荷运转", + "type": "concern", + "status": "confirmed", + "source": "量潮云 · 项目数据", + "date": "5月7日", + "linkedToStrategy": true + }, + { + "id": "d2", + "text": "连续3个小项目利润率为负,占用了核心团队时间却未产生合理收益", + "type": "risk", + "status": "confirmed", + "source": "量潮云 · 财务数据", + "date": "5月7日", + "linkedToStrategy": true + }, + { + "id": "d3", + "text": "近一个月无主动销售行为,所有项目机会均来自老客户复购或被动咨询", + "type": "concern", + "status": "pending", + "source": "量潮云 · 销售看板", + "date": "5月7日", + "linkedToStrategy": true + }, + { + "id": "d4", + "text": "咨询平台原型已跑通,客户反馈正面", + "type": "opportunity", + "status": "confirmed", + "source": "量潮云 · 项目数据", + "date": "5月6日", + "linkedToStrategy": false + } + ], + "communications": [], + "revisions": [ + { + "id": "r1", + "date": "5月7日", + "reason": "发现产能利用率低 + 小项目利润率为负 → 策略转向大项目路线", + "relatedDiscoveryId": "d1", + "isReviewed": true + }, + { + "id": "r2", + "date": "5月7日", + "reason": "发现无主动销售行为 → 需建立市场机制,不再依赖被动获客", + "relatedDiscoveryId": "d3", + "isReviewed": false + } + ], + "stakeholders": [ + { + "id": "s1", + "name": "创始人", + "role": "最终决策者", + "stance": "support", + "concern": "关注平台化与可持续增长机制", + "detail": "对双平台架构有清晰认知,但执行上容易陷入细节。需要秘书处分担协调事务,聚焦战略决策。" + }, + { + "id": "s2", + "name": "团队", + "role": "执行层", + "stance": "neutral", + "concern": "关注工作强度与技能成长", + "detail": "核心团队能力在快速提升,但项目类型杂导致聚焦困难。需要更清晰的项目分工和能力建设路径。" + }, + { + "id": "s3", + "name": "客户市场", + "role": "外部环境", + "stance": "neutral", + "concern": "关注交付质量与响应速度", + "detail": "市场对量潮的认知仍停留在小项目阶段,需要标杆大单来重塑品牌定位。" + } + ] +} diff --git a/assets/fixtures/founder/dashboard.json b/assets/fixtures/founder/dashboard.json new file mode 100644 index 00000000..6a05479a --- /dev/null +++ b/assets/fixtures/founder/dashboard.json @@ -0,0 +1,19 @@ +{ + "businessUnits": [ + { + "name": "思考", + "tag": "", + "isPrimary": true, + "screenType": "thinking", + "decisions": [] + }, + { + "name": "写作", + "tag": "", + "isPrimary": true, + "screenType": "writing", + "decisions": [] + } + ], + "functionCards": [] +} diff --git a/assets/fixtures/founder/metadata.json b/assets/fixtures/founder/metadata.json new file mode 100644 index 00000000..89ea06d9 --- /dev/null +++ b/assets/fixtures/founder/metadata.json @@ -0,0 +1,12 @@ +{ + "sections": [ + { + "id": "dashboard", + "items": ["dashboard"] + }, + { + "id": "business", + "items": ["thinking", "writing"] + } + ] +} diff --git a/assets/fixtures/founder/thinking.json b/assets/fixtures/founder/thinking.json new file mode 100644 index 00000000..3aaf9296 --- /dev/null +++ b/assets/fixtures/founder/thinking.json @@ -0,0 +1,82 @@ +{ + "title": "认知建构与思维演进", + "subtitle": "基于 2026.03.11 - 2026.05.05 日志的分析报告", + "period": "46天日志记录了一次从\"方法的建立\"到\"系统的反思\"再到\"视角的外化\"的连贯心智旅程。", + "awarenessSection": { + "label": "情境意识", + "icon": "explore_outlined", + "color": "#5B8DEF" + }, + "stages": [ + { + "icon": "construction_outlined", + "title": "奠基期(3月中旬 - 3月底)", + "subtitle": "方法与工具的归档", + "points": [ + "核心:日志格式、知识库、AI模型、工作手册", + "有意识地设计一套思维脚手架,为深度探索打下方法论基础" + ], + "color": "#5B8DEF" + }, + { + "icon": "auto_awesome_outlined", + "title": "爆发与深化期(4月)", + "subtitle": "认知内核的建模与重构", + "points": [ + "4月23日达思想高峰(单日12,748字,启发61次),认知集中突破", + "触及元认知层面——反思\"我是如何思考的\"", + "将AI作为新的认知工具和比较对象纳入思维过程" + ], + "color": "#E8A838" + }, + { + "icon": "rocket_launch_outlined", + "title": "外化与应用期(4月底 - 5月初)", + "subtitle": "从思想到产品与叙事", + "points": [ + "思考重心从内部认知架构转向外部的实践与产品化", + "开始面向\"用户\"和\"市场\"——\"这台机器的用户是谁?\"", + "\"困惑\"增多,反映将想法落地的实际挑战" + ], + "color": "#4CAF50" + } + ], + "emotions": [ + { "label": "启发/顿悟", "value": "450次", "color": "#4CAF50" }, + { "label": "困惑/混沌", "value": "127次", "color": "#E8A838" }, + { "label": "压力/焦虑", "value": "80次", "color": "#EF5350" } + ], + "emotionNote": "主导情绪是\"启发/顿悟\"——这不是情绪日记,而是一份认知收获日记。困难是启发的燃料。", + "insightSection": { + "label": "心智模型", + "icon": "psychology_outlined", + "color": "#7C4DFF" + }, + "insights": [ + { + "icon": "chat_outlined", + "title": "AI 作为持续对话者与参照系", + "description": "AI 不只是工具,更是对等的思考伙伴。通过与之互动,反身性地定义和理解人类思维的独特性。" + }, + { + "icon": "transform_outlined", + "title": "从\"动词\"到\"名词\"的认知固化", + "description": "早期多为\"整理\"\"归档\"等动作,后期\"资产\"\"标准\"\"平台\"等名词性概念更为核心——流动的想法正凝结为可迭代的实体。" + }, + { + "icon": "touch_app_outlined", + "title": "\"感觉\"作为探测器与压力测试器", + "description": "\"感觉\"出现 309 次,既是发现问题的探测器(\"感觉哪里不对\"),也是系统设计的压力测试器(\"这个用起来感觉很奇怪\")。" + }, + { + "icon": "short_text_outlined", + "title": "\"就是说\"作为思维连接词", + "description": "高频出现(175次),标志持续的自我解释与精炼——将模糊想法用更底层的方式重新表述,是深度思维的显著特征。" + } + ], + "closing": { + "title": "感知 — 建模 — 应用", + "description": "46天的日志清晰地构建并记录了一条\"感知-建模-应用\"的认知演化路径。已经从单纯的记录者,成长为主动构建个人思想和知识系统的架构师。", + "quote": "最宝贵的资产,是日志中所展现的那种持续、敏锐、并不断尝试自我超越的思维习惯本身。" + } +} diff --git a/assets/fixtures/metadata.json b/assets/fixtures/metadata.json new file mode 100644 index 00000000..1751fce0 --- /dev/null +++ b/assets/fixtures/metadata.json @@ -0,0 +1,11 @@ +{ + "workspaces": [ + { "id": "internal", "name": "量潮创始人", "icon": "person_outline", "dir": "founder" }, + { "id": "customer", "name": "量潮科技", "icon": "business_outlined", "dir": "company" } + ], + "sections": [ + { "id": "dashboard", "dividerBefore": false }, + { "id": "business", "dividerBefore": true }, + { "id": "function", "dividerBefore": true } + ] +} diff --git a/assets/videos/studio.mp4 b/assets/videos/studio.mp4 new file mode 100644 index 00000000..cabc41fd --- /dev/null +++ b/assets/videos/studio.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0bfd30df06af3859d6c174081568fedd9aaec4d492e970ed2ee2c04bc0549f9 +size 1131104 diff --git a/doc/README.md b/doc/README.md deleted file mode 100644 index e0899d07..00000000 --- a/doc/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# 开发者文档 - -目标用户:参与开发或维护`qtadmin-studio`项目文档的技术人员。 -核心目的:指导开发者如何构建或维护本项目。 - -## 基本骨架 - -页面左边是侧边导航栏,右边是正文。 - -侧边导航栏使用NavigationRail实现。 diff --git a/doc/navigation_widget.md b/doc/navigation_widget.md deleted file mode 100644 index d716ede9..00000000 --- a/doc/navigation_widget.md +++ /dev/null @@ -1,6 +0,0 @@ -# 导航栏组件 - -## 功能 - -- 列举:显示导航列表,能够数清楚导航按钮数量。 -- 跳转:点击跳转到某个页面路由,能够获取到新页面的信息。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..da646304 --- /dev/null +++ b/docs/README.md @@ -0,0 +1 @@ +# 工作文档 diff --git a/docs/add/README.md b/docs/add/README.md new file mode 100644 index 00000000..4bfb5ba5 --- /dev/null +++ b/docs/add/README.md @@ -0,0 +1,15 @@ +# 架构决策文档 (ADD) + +本目录存放架构决策记录,回答**"为什么"**。 + +## 在 + +- 技术选型理由 +- 设计方案对比与取舍 +- 跨领域约束与原则 + +## 不在 + +- 不写数据 schema(在 `docs/drd/`) +- 不写实现细节(在 `src/studio/doc/`) +- 不写操作指南 diff --git a/docs/add/hr-email-import.md b/docs/add/hr-email-import.md new file mode 100644 index 00000000..4c9ef354 --- /dev/null +++ b/docs/add/hr-email-import.md @@ -0,0 +1,217 @@ +# 招聘邮箱导入程序设计 + +## 问题 + +人力资源团队使用招聘专用邮箱(如 `zhaopin@quanttide.com`)接收简历投递、面试安排、录用沟通等邮件。目前这些邮件散落在邮箱中,没有结构化的候选人数据管理。 + +`docs/user-guide/human.md` 中有占位命令 `qtadmin human xxxxx`,描述为"使用 lark-cli 获取招聘邮箱数据并提交到服务端",但无实现。 + +## 设计目标 + +- 将招聘邮件从邮箱中导入为结构化候选人数据 +- 自动分类邮件类型(简历投递 / 面试邀请 / 录用通知 / 拒信) +- 提取候选人关键信息(姓名、岗位、联系方式) +- 支持增量导入和持续监控 +- 与 provider API 对接持久化数据 + +## 整体架构 + +``` +┌──────────────┐ subprocess ┌──────────────────┐ HTTP ┌──────────────┐ +│ lark-cli │ ◄──────────────► │ qtadmin human │ ────────► │ Provider │ +│ (mail API) │ │ import-email │ │ (FastAPI) │ +│ │ │ │ │ │ +│ +triage │ ──邮件列表────── │ 1. fetch │ │ POST /hr/ │ +│ +message │ ──邮件详情────── │ 2. classify │ │ candidates │ +│ attachments │ ──附件下载────── │ 3. extract │ │ POST /hr/ │ +│ │ │ 4. submit │ │ emails │ +└──────────────┘ └──────────────────┘ └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ PostgreSQL │ + │ (via SQLite │ + │ for dev) │ + └──────────────┘ +``` + +### 分层职责 + +| 层 | 职责 | 技术 | +|---|---|---| +| **Connector** | 通过 lark-cli 访问招聘邮箱 | CLI subprocess 调用 `lark-cli mail` | +| **Pipeline** | 获取 → 分类 → 提取 → 提交 | Typer 命令编排 | +| **Provider** | 持久化候选人数据 | FastAPI + SQLAlchemy | +| **Storage** | 数据存储 | PostgreSQL(生产)/ SQLite(开发) | + +## CLI 设计 + +### 命令树 + +``` +qtadmin human +├── connect 测试邮箱连接 +├── import-email 全量导入(fetch + classify + extract + submit) +│ ├── --mailbox 指定邮箱地址(默认配置文件中的招聘邮箱) +│ ├── --days 导入近 N 天的邮件(默认 30) +│ ├── --limit 最大导入数 +│ ├── --dry-run 预览模式,不提交 +│ ├── --since 指定起始日期 +│ └── --watch 持续监控模式 +├── emails list 查看已导入的邮件 +├── candidates list 查看已提取的候选人 +└── classify 手动分类单封邮件 +``` + +### import-email 流程 + +``` +import-email + │ + ├── 1. fetch ── lark-cli mail +triage → 获取未处理的邮件列表 + │ └── 过滤:排除已导入的(按 message_id 去重) + │ + ├── 2. read ── lark-cli mail +message → 逐封读取详情 + │ └── 含正文、发件人、收件人、主题、附件元数据 + │ + ├── 3. classify ── 规则分类 + │ ├── resume 简历投递 — 含附件简历 + │ ├── interview 面试邀请 — 主题含"面试"/"interview" + │ ├── offer 录用通知 — 主题含"offer"/"录用" + │ ├── rejection 拒信 — 主题含"感谢"/"unfortunately" + │ └── other 其他 + │ + ├── 4. extract ── 从邮件中提取结构化信息 + │ ├── candidateName 候选人姓名(从正文/签名/附件推断) + │ ├── position 应聘岗位(从主题/正文提取) + │ ├── email 发件人邮箱 + │ ├── phone 联系方式(从正文正则匹配) + │ ├── attachments 附件列表(简历文件) + │ └── summary 邮件摘要 + │ + ├── 5. download ── lark-cli mail attachments → 下载附件简历 + │ + ├── 6. submit ── POST 到 provider API + │ ├── POST /hr/emails 保存邮件记录 + │ └── POST /hr/candidates 保存候选人(如已分类为 resume) + │ + └── 7. report ── 打印导入结果汇总 +``` + +### 设计原则(来自 clig.dev) + +- **默认存草稿,确认后发送**:`--dry-run` 预览变更,不加则询问确认 +- **输出示例**:每次运行打印汇总表 +- **退出码**:成功 0,部分失败 1,完全失败 2 +- **标准 flag 名**:`--dry-run`, `--limit`, `--since` 等 + +## Provider API 设计 + +### 数据模型 + +```python +# Candidate +candidate: + id: UUID + name: str # 候选人姓名 + email: str # 发件人邮箱 + phone: str? # 联系方式 + position: str? # 应聘岗位 + source: str # 来源渠道 ("email") + source_email_id: UUID # 关联邮件 + resume_file_url: str? # 简历文件地址 + status: str # new / contacted / interviewed / offered / hired / rejected + created_at: datetime + updated_at: datetime + +# RecruitmentEmail +email: + id: UUID + message_id: str # lark-cli message_id(去重依据) + mailbox: str # 邮箱地址 + subject: str + sender_name: str + sender_email: str + received_at: datetime + category: str # resume / interview / offer / rejection / other + body_text: str? # 纯文本正文 + has_attachments: bool + attachment_metadata: json? # 附件列表 [{name, size, type}] + is_imported: bool + imported_at: datetime? + +# ImportLog +import_log: + id: UUID + run_at: datetime + total_emails: int + imported_count: int + skipped_count: int + failed_count: int + errors: json? +``` + +### 端点 + +```python +POST /api/v1/hr/emails # 批量提交导入的邮件 +POST /api/v1/hr/candidates # 创建候选人(从简历邮件提取) +GET /api/v1/hr/candidates # 候选人列表(支持筛选) +GET /api/v1/hr/candidates/:id # 候选人详情 +PATCH /api/v1/hr/candidates/:id # 更新候选人状态 +GET /api/v1/hr/import-logs # 导入历史 +``` + +## 数据分类规则 + +邮件分类使用关键词规则,初始版本无需 ML: + +| 类别 | 判定条件 | 优先级 | +|---|---|---| +| **resume** | 有附件(.pdf/.doc/.docx)且主题/正文含"简历"/"应聘"/"求职"/"application" | 最高 | +| **offer** | 主题含"offer"/"录用"/"入职通知" | 高 | +| **interview** | 主题含"面试"/"interview"/"邀约" | 高 | +| **rejection** | 主题含"感谢投递"/"unfortunately"/"不合适" | 中 | +| **other** | 默认 | 最低 | + +## 分阶段实施 + +### Phase 1 — CLI 获取+本地保存 + +- 实现 `qtadmin human import-email --dry-run`,将邮件数据保存到本地 JSON 文件 +- 不依赖 provider,不依赖数据库 +- 可手动审核分类结果 + +### Phase 2 — Provider API + 数据库 + +- 在 provider 中添加 SQLAlchemy + SQLite +- 实现数据模型和 CRUD 端点 +- CLI 加上 `--submit` 模式,对接 provider API + +### Phase 3 — 简历解析 + +- 集成简历解析(python-resume-parser 或类似库) +- 从 PDF/DOCX 中提取结构化简历信息 +- 与候选人数据合并 + +### Phase 4 — 持续监控 + +- 实现 `--watch` 模式,使用 lark-cli mail +watch 实时监听新邮件 +- 新邮件到达自动触发导入 +- 可选:发送飞书 IM 通知给 HR + +## 设计取舍 + +| 取舍 | 选择 | 代价 | +|---|---|---| +| CLI 调用 lark-cli vs 直接使用 Lark OAPI SDK | CLI subprocess 调用 lark-cli | 多一层进程开销,依赖本地安装 lark-cli | +| 规则分类 vs ML 分类 | 初始用规则,预留 ML 接口 | 泛化能力有限,需持续维护规则 | +| JSON 文件中间态 vs 直写数据库 | Phase 1 先落文件,Phase 2 再落库 | Phase 1→2 需做数据迁移 | +| SQLite vs PostgreSQL | 开发用 SQLite,生产用 PostgreSQL(SQLAlchemy 抽象) | 需注意方言差异 | + +## 不解决的问题 + +- **简历解析的准确性**:Phase 3 评估后决定是否引入 ML 模型 +- **多邮箱聚合**:当前只支持单招聘邮箱,多邮箱需后续扩展 +- **候选人去重**:同一候选人多次投递的去重策略需后续定义 +- **与现有 HR 系统对接**:不替换现有 HR 系统,数据由 HR 团队确认后手动导出 diff --git a/docs/add/qtclass.md b/docs/add/qtclass.md new file mode 100644 index 00000000..cd96a33e --- /dev/null +++ b/docs/add/qtclass.md @@ -0,0 +1,150 @@ +# qtclass 架构设计 + +## 问题 + +量潮课堂的四个组成部分(校企合作、实训基地、内部教学、一对一)在数据模型中只是四个枚举值。没有课程、没有学员、没有合作方——只有几张卡片和硬编码的统计数字。 + +之前的方案设计了 Program 作为连接内外视角的桥接实体,但 Program 试图同时承载课程属性、客户属性、合作方属性,导致职责不清: + +``` +// 旧方案:Program 承担了太多职责 +Program: + name ← 课程名称 + componentType ← 交付模式 + customerType ← 客户类型(跨领域) + partnerOrgId ← 合作方(跨领域) + startDate ← 课程时间 + studentCount ← 学员统计 + revenue ← 财务数据 +``` + +一个实体横跨课程、组织、财务三个领域,既不是课程、也不是合同、也不是学员——什么都不是。 + +## 领域拆分 + +将课堂业务划分为两个独立领域,通过**交付模式**建立关联: + +``` +┌─────────────────────────────────────────────────┐ +│ 课程领域 │ +│ (教什么、怎么教、谁来教) │ +│ │ +│ Course ──has──▶ Class ──has──▶ Lesson │ +│ │ │ │ +│ └── syllabus └── teacher, schedule │ +└────────────────────┬──────────────────────────────┘ + │ 交付模式 + ▼ (校企/实训/内部/一对一) +┌────────────────────┴──────────────────────────────┐ +│ 组织领域 │ +│ (谁学、谁合作、谁付费) │ +│ │ +│ Student ──enrolls──▶ Enrollment │ +│ Organization ──partners──▶ Partnership │ +│ Customer ──contracts──▶ Contract │ +└─────────────────────────────────────────────────┘ +``` + +### 课程领域(Course Context) + +关注教学内容和交付过程,不关心谁来买单: + +| 实体 | 职责 | 示例 | +|---|---|---| +| **Course** | 课程定义,稳定的教学单元 | "Python 数据分析" | +| **Class** | 课程的一次具体开课 | "杭电 2026 春 Python 实训班" | +| **Lesson** | 单次授课 | "3月15日 14:00-16:00 函数式编程" | +| **Teacher** | 授课人 | "王老师" | +| **Syllabus** | 教学大纲,知识点结构 | 8 个章节、3 个实践项目 | + +### 组织领域(Organization Context) + +关注参与者关系和商业合同,不关心教学细节: + +| 实体 | 职责 | 示例 | +|---|---|---| +| **Student** | 学员个人信息与学习轨迹 | 可跨多个 Class 跟踪 | +| **Organization** | 外部合作机构(院校/企业) | "杭州电子科技大学" | +| **Customer** | 内部客户视图,关联合同 | B2B / B2C / 高校 / 内部 | +| **Contract** | 商业合同,约定金额与交付范围 | "杭电 Python 实训合同 ¥120,000" | + +## 交付模式:两个领域的连接点 + +四个组成部分不是领域实体,而是**交付模式(DeliveryMode)**——描述课程以何种方式交付给组织侧的参与者: + +``` + 课程端 组织端 + ┌─────────────────────────┐ ┌─────────────────────────┐ + │ Course: Python 数据分析 │ │ Organization: 杭电 │ + │ Class: 2026春实训班 │──交付模式──│ Student: 张三、李四... │ + │ Teacher: 王老师 │ 校企合作 │ Contract: ¥120,000 │ + └─────────────────────────┘ └─────────────────────────┘ +``` + +### DeliveryMode 的定义 + +``` +DeliveryMode: + id, name // 如 "校企合作" + category // schoolEnterprise / trainingBase / internalTeaching / oneOnOne + constraints: // 该模式的约束规则 + - requiresPartner // 是否需要合作方 + - maxStudents // 最大学员数 + - billingModel // 按项目 / 按课时 / 按人头 +``` + +交付模式是一个**配置级的枚举**,不是实体——它不单独存储业务数据,而是为 Class 和 Contract 提供约束规则。新增交付模式只需加一行配置 + fixture,不改代码。 + +## 关键关系 + +``` +组织侧 课程侧 +Organization ──▶ Contract ──▶ Class ──▶ Course + │ │ + │ └── DeliveryMode(code) + │ + ▼ + Enrollment + │ + ▼ + Student +``` + +- **Contract 连接组织与课程**:一份合同约定了一个 Org 对某个 Class 的购买。合同上有金额、交付物、时间线 +- **Enrollment 连接学员与课程**:学员报名某个 Class。一个学员可以报名不同的 Class +- **DeliveryMode 是 Contract 上的一个属性**:合同签订时即确定以什么模式交付 +- **Class 本身不感知组织**:同一个 Class 可以被不同的 Contract 覆盖(如企业包班 + 个人散招混合) + +## 与之前方案的区别 + +| | 旧方案(Program 桥接) | 新方案(领域分离) | +|---|---|---| +| 核心实体 | Program(模糊聚合) | Course / Class / Contract(职责明确) | +| 领域边界 | 无,所有字段揉在一个模型里 | 课程域 ↔ 组织域通过 Contract 连接 | +| 交付模式 | ComponentType 是枚举,无约束 | DeliveryMode 是配置,可约束行为 | +| 学员跟踪 | 通过 Enrollment 挂在 Program 下 | 通过 Enrollment 挂在 Class 下,更细粒度 | +| 合同管理 | 无独立 Contract 实体 | Contract 显式建模,连接组织与课程 | +| 扩展性 | 新增模式改枚举 | 新增模式加 DeliveryMode 配置行 | + +## 设计规则 + +1. **课程域不引用组织域**——Class 不知道谁买单,Course 不知道谁在学习。课程只关心教学本身。 +2. **组织域不引用课程内容**——Contract 不知道 syllabus 是什么,Student 不关心教学大纲。组织只关心参与关系和商业条款。 +3. **交付模式是配置,不是实体**——四种交付模式的定义从 fixture 加载,不编译在代码里。新增模式只需加 JSON。 +4. **数据驱动 + 惰性演进**——当前 v0.5 仍是卡片展示。v0.6 先落 Course + Class(课程域),v0.7 再落 Contract + Enrollment(组织域)。两个领域可以不同步上线。 + +## Trade-offs + +| 取舍 | 选择 | 代价 | +|---|---|---| +| 领域独立 vs 查询便利 | 两域物理分离 | 跨域查询需要关联 Contract | +| Contract 作为连接点 vs Enrollment 直接连接 | Contract 居中,更贴近真实业务 | 多一次 join | +| 交付模式配置化 vs 硬编码 | JSON 配置,运行时加载 | 校验逻辑需提前定义 | +| 分领域落地 vs 一次性建模 | 课程域优先,组织域延后 | 短期无法回答"这个客户赚了多少钱" | + +## 不解决的问题 + +- **教学质量管理**:评分、反馈、作业批改——这些属于教学评估领域,不在课程域和组织域内 +- **排课与资源调度**:教室、设备、时间冲突检测——独立的排课模块 +- **支付与发票**:资金流不属于课堂的领域边界 +- **学员端**:学员查看课程表、成绩的独立入口——需要时作为独立 bounded context 引入 diff --git a/docs/brd/index.md b/docs/brd/index.md new file mode 100644 index 00000000..09e1149c --- /dev/null +++ b/docs/brd/index.md @@ -0,0 +1,175 @@ +# 第二大脑业务需求说明书 + +## 场景一:今天要不要接这个项目? + +**决策频率**:每周多次。**决策者**:项目负责人。 + +### 业务映射 + +量潮的订单特征是"数千到一两万、几周结项、质量要求高"。接不接不是一个收入问题,是一个效率问题——团队只有几个人,每个项目都需要从零培养的人才去交付。接了一个低利润、高沟通成本的项目,等于占用了本该用在复购客户上的产能。 + +同时量潮有两条业务线(数据 + 教育)在跑产教融合循环。如果两类项目比例失衡,融合循环就会断掉——只做数据项目,教育侧的知识积累停滞,长期拿不到教育订单;反过来也一样。所以"接不接"的决策实际是在同时管理现金流、产能和知识结构三条线。 + +需求侧还有一层:获客困难,每一个项目机会都来之不易。放弃一个机会的心理成本很高,所以更容易"先接了再说"——然后产能过载,交付质量下降,反而伤害信誉。 + +### 当前做法 + +查表格看当前产能 → 问财务看现金流 → 翻聊天记录回忆客户背景 → 凭感觉拍板。 + +### 决策现场 + +项目面板只展示三样东西: + +1. **当前容量** — 团队同时能跑 `N` 个项目,现在跑了 `M` 个,还剩 `N-M` 个。满了直接告诉你"现在接不了",而不是弹一个空白的"新建项目"表单。 + +2. **正在跑的项目** — 每个项目显示进度条、客户、金额。进度落后预期 20% 以上的,卡片左侧色条变红——不是硬阈值报警,是告诉你"这个项目需要你注意,接新项目前先想清楚"。 + +3. **待承接的项目** — 每个待接项目只问三个问题: + - 金额够不够覆盖团队一周的成本? → 绿/黄/红(量潮的订单利润薄,几千块的单子可能刚好 cover 一周人力) + - 客户是回头客还是新客? → 显示复购次数(海外客户、高校客户、企业客户的复购率不同) + - 项目类型(数据/教育)和当前产能缺口匹配吗? → 教育类积压时数据类优先,维持融合循环平衡 + +### 决策之后 + +点了"接",系统自动:在项目管道插入记录 → 锁定一个空闲成员 → 按项目类型增加知识积累值。不需要填表单,不需要切页面。决策摩擦约等于点一次确认。 + +### 替代路径 + +当系统积累了足够多的"接/不接"历史决策后,开始显示推荐置信度:"推荐「接」,匹配度 87%"。负责人可以采纳,也可以推翻。系统记录每次偏差来校准模型。直到连续三个月没有推翻过推荐——**这个决策已经可以交给系统了。** + +--- + +## 场景二:这件事该找谁? + +**决策频率**:每天多次。**决策者**:任何需要协作的人。 + +### 业务映射 + +量潮的人才几乎都是从零培养的。供给侧的现实是"高校教育体系落后保守,需要的人才都得自己带"。这就意味着: + +- 每个人的技能画像是不规则的——能做数据清洗的人不一定能写教案 +- 跨业务线调动频繁——今天在教育项目上的人,下周可能被拉到数据项目上 +- 客户遍布全球十几个国家,时区和语言带来额外的协作摩擦 + +加上团队的矩阵式组织架构(项目线 + 职能线),传统"查组织架构图"的方式完全失效——你需要的不是一个头衔,而是一个"现在有空、技能匹配、之前和这个客户打过交道"的人。 + +### 当前做法 + +在群里问"这个谁负责?" → 等有人回复 → 或者猜一个名字发过去 → 被转给另一个人。 + +### 决策现场 + +在任意知识对象(项目、客户、任务)的页面上,都有一个区域叫**相关的人**: + +- 谁对这个客户最熟 → 显示最近 3 次交互记录(全球十几个国家的客户,谁跟过最清楚) +- 谁在做同类项目 → 显示当前相似项目列表(数据类/教育类) +- 谁的技能匹配这个需求 → 显示技能标签匹配度和当前负载 + +不需要查组织架构图。不需要问人。 + +### 替代路径 + +当系统发现你总是指定同一个人处理某类问题时,自动提示:"需要把这类问题自动转给张三吗?" 接受后,相关请求直达对应的人。直到有一天,你不再需要去想"该找谁"——因为系统已经在你问之前就转过去了。 + +--- + +## 场景三:这个决策当时是怎么来的? + +**决策频率**:每周几次(复盘/客户质疑/新人问起)。**决策者**:所有需要回溯上下文的人。 + +### 业务映射 + +量潮面临的核心问题是"如何把隐式的基于具体情境的经验提炼成显式的基于文本的经验"。这有两个驱动力: + +一是制度建设的需要。从人治到法治的关键一步是"决策可回溯"——当一个新人问"为什么当时选了这个方案"时,答案不能是"老张说的"。 + +二是产教融合循环本身的高度耦合。一个教育项目的决策(比如调整课程内容)会影响数据项目的排期(因为讲师被调走了),影响实习生的培养计划(因为课程调整了教学节奏),影响客户的复购意愿(因为交付时间变了)。这些跨对象的因果链如果不能被记录和回溯,每次复盘就只能靠当事人回忆。 + +### 当前做法 + +翻聊天记录 → 翻邮件 → 翻文档 → 拼凑出一个大概。关键细节经常丢失。 + +### 决策现场 + +每条决策记录是一张卡片,而不是一行日志。卡片上只有四块: + +1. **在什么情境下做的** — 当时项目进度、财务状态、可选方案 +2. **谁参与了** — 参与人列表及每个人当时的输入 +3. **选了哪个方案** — 选了的方案和没选的理由 +4. **结果怎么样** — 事后回填的结论标签(正确/有偏差/错误) + +### 替代路径 + +当系统积累到足够多的决策卡片后,开始做两件事: +- 识别重复出现的决策模式,生成模板:"这个情况上次也是这么处理的,要复用吗?" +- 识别长期错误的决策模式,推送反思:"连续 3 次这个类型的决策结果都是负面的,要不要调整判断逻辑?" + +--- + +## 场景四:这条信息该记吗? + +**决策频率**:每天数十次。**决策者**:所有人。 + +### 业务映射 + +量潮面临的挑战之一是"显式化隐性经验"。团队协作中的大量信息是情境化的:和客户喝咖啡时聊到的需求变更、看到一篇论文想到的数据处理方法、实习生反馈的教学盲点。这些信息在被记录的当下是清晰的,但如果不立刻记下来,几小时后上下文就丢了。 + +同时,产教融合循环本身是一个信息密集型系统——数据项目的技术积累应该反哺教育项目的内容,教育项目的教学反馈应该指导数据项目的方法论。这个循环的前提是两边产生的信息能被对方看到。如果信息散落在各自的笔记和聊天记录里,融合就无从谈起。 + +最后,"如何确保新人会接受规范而不让规范流失"这个问题也依赖于信息的结构化沉淀——规范不能只存在于创始人的脑子里或一本 60 页的手册里,它应该出现在新人做决策的那一刻。 + +### 当前做法 + +记了 → 以后找不到。不记 → 需要时想不起来。最终选择:要么什么都记(信息过载),要么什么都不记(信息丢失)。 + +### 决策现场 + +系统不要求用户自己判断"该不该记"。用户只需要保持自然的工作状态——写日志、开会、回消息。系统自动: + +- 提取关键信息(客户承诺、变更决定、时间节点) +- 分类到对应的知识对象(项目、客户、决策) +- 按紧急程度排列在相关对象的"待处理"区 + +用户不需要在任何时候停下来"整理"。整理是系统的事,不是人的事。 + +### 替代路径 + +系统开始预测你需要什么信息。在开会前推送相关项目的最新动态,在写日志时提示"上次这个客户的事后来怎么样了?要追一下吗?"——信息不是等人来找,而是主动出现在决策发生之前。 + +--- + +## 场景五:这个活干完了,然后呢? + +**决策频率**:每个项目结束一次。**决策者**:项目负责人。 + +### 业务映射 + +量潮的两条业务线都需要通过项目沉淀可复用的资产。 + +数据业务:每个项目都会积累数据处理的经验(清洗规则、分析框架、行业知识),但大部分项目之间的做法是不互通的。下一个类似项目来了,从零开始摸索一遍——这对"效率敏感"的商业模式是致命的。 + +教育业务:浙理工的合作是一个标杆案例,证明了"企业-高校深度合作"的商业模式可行。但这个模式的可复制性取决于能不能从单一案例中抽象出可复用的方法论——而不是每次面对新学校时都从"老师好,我们公司是做什么的"开始。 + +标准化体系的建设正是在解决这个问题。每个项目结束时,系统能从执行过程中沉淀出流程资产,让下一个项目站在前一个的肩膀上。 + +### 当前做法 + +项目交付 → 写一份总结文档 → 存档 → 再也不看。下一个类似项目重新踩一遍同样的坑。 + +### 决策现场 + +项目结束后,系统问三个问题: + +1. **哪些做法可以复用?** → 自动提取本次项目中效率最高的流程环节 +2. **哪些坑下次可以避免?** → 自动提取延期、返工、沟通失误的关键节点 +3. **这产生了哪些新知识?** → 自动统计项目中积累的文档、代码、决策记录 + +这些不需要手动填写,是在项目执行过程中自然沉淀的。负责人只需要确认和补充。 + +### 替代路径 + +当系统积累了足够多的项目结束后,新项目启动时自动注入相关经验:"上一个和这个类似的项目用了 10 周,其中数据清洗环节花了 3 周。如果这次复用上次的工具链,预计可以缩短到 2 周。" + +--- + +这些场景覆盖了从接项目、找人、回溯决策、信息捕捉、到项目收尾的完整链条。每个场景的终点都是同一个方向:**让系统逐步替代自己的判断**,而非让系统帮自己记得更多。 diff --git a/docs/brd/qtconsult.md b/docs/brd/qtconsult.md new file mode 100644 index 00000000..4cdec99f --- /dev/null +++ b/docs/brd/qtconsult.md @@ -0,0 +1,71 @@ +# IT咨询服务业务需求说明书 + +## 服务定位 + +为企业客户提供IT咨询,核心目标是以技术手段驱动企业完成数字化转型与智能化转型。咨询团队需要将自身的技术能力与客户的战略诉求进行精准匹配,而非推销标准化的技术方案。 + +## 核心痛点:信息-策略断层 + +咨询项目中最常见的问题不是"不了解客户",而是**对客户的新发现和团队制定的策略之间出现脱节**。发现归发现,策略归策略,等到发现影响策略时,已经过了关键窗口期。 + +具体表现: + +- 调研会上发现"中层普遍抗拒变革",记在会议纪要里,但策略中没有体现应对中层的内容 +- IT人力不足是硬约束,方案里仍写"全量系统迁移",没有人把这两件事关联审视 +- 财务负责人从反对变为中立,态度已经转变,但策略中仍把财务视为阻力,没有调整应对方式 + +问题根源在于:发现和策略之间没有强制关联。信息被记录了,但没有被转化为行动。 + +## 业务模型 + +咨询服务的核心工作流是一条闭环: + +``` +发现新情况 → 审视当前策略 → 调整或不调整 → 继续执行 → 发现新情况… +``` + +每个阶段的具体形态不同,但循环机制不变: + +- **接洽期**:用有限的初步信息判断要不要接、怎么切入 +- **方案期**:每次接触后的新发现自动触发策略审视 +- **交付期**:执行中的实际反馈反向修正策略 +- **复盘期**:完整的认知-策略演变轨迹沉淀为可复用的方法论 + +## 系统价值 + +系统要解决的核心问题是:**让每一条关键发现都能自动触发策略审视**,不让信息沉淀在纪要里而策略纹丝不动。 + +具体来说,需要做到三件事: + +1. **发现可溯源、可触发动作** — 每条发现都有类型(风险/机会/中性)、状态(待确认/已确认)、来源(哪次会议),高风险发现自动在策略侧生成一条"待审视"记录 +2. **策略可追踪、可回溯** — 策略是活的文档,每次调整都记录原因和时间,团队能回溯"为什么当时加了这一步" +3. **利益相关者管理嵌入日常决策** — 决策链上每个人的立场和顾虑在策略调整时自然浮现,提醒顾问"这个调整会影响谁" + +## 长期策略 + +项目的认知资产不应随着项目结束而流失。长期需要建立: + +- **发现-策略匹配模式**:识别反复出现的发现类型和对应的有效策略,形成模式库 +- **行业认知图谱**:跨项目积累行业级的发现集合和策略模板,新项目不再从零开始 +- **决策链演变模型**:积累不同角色在咨询过程中的态度变化规律,预判沟通重点 + +## 双Workspace工作空间模型 + +量潮咨询不只是对外交付的工具,它同时服务于两个Workspace工作空间: + +### 客户Workspace工作空间(对外交付) + +量潮科技用来为客户做咨询。这是 BRD 前述所有场景的默认上下文。 + +### 内部Workspace工作空间(自我观察) + +组织内任一主体都可以用量潮咨询进行自我观察。内部Workspace工作空间的"被咨询者"就是该主体自身。 + +可能的参与者: + +- **创始人**——观察量潮科技的战略和认知 +- **量潮科技(组织)**——观察自身的运营效率和管理问题 + +核心区别在于数据源——内部Workspace工作空间的"发现"来自量潮云(公司对自己的陈述),观察者以独立身份审视这些发现,形成策略调整建议。 + +Workspace工作空间隔离在此的意义不是数据安全,而是**认知隔离**——强制制造观察者与被观察者的结构边界,让观察者从内部叙事中抽离出来,获得一个无法被内部叙事同化的外部视角。量潮云是"公司说它是什么",内部Workspace工作空间的量潮咨询是"一个独立观察者说公司是什么",两者之间的偏差就是成长空间。 diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 00000000..d5fde71f --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,9 @@ +# 开发文档 + +记录开发过程中的关键决策和技术债务评估。 + +## 规范 + +- 每个模块一个子目录,按模块名独立维护 +- `decision.md` 记录架构/技术选型决策及其理由 +- `refactor.md` 记录重构评估、技术债务评级和变化总结 diff --git a/docs/dev/studio/decision.md b/docs/dev/studio/decision.md new file mode 100644 index 00000000..991cdec4 --- /dev/null +++ b/docs/dev/studio/decision.md @@ -0,0 +1,8 @@ +# Studio 关键决策 + +| 决策 | 替代方案 | 理由 | +|:-----|:---------|:-----| +| flutter_bloc 替代 setState | Provider/riverpod | 事件驱动适合 consult_screen 的添加/确认/驳回/删除操作链 | +| _SidebarShell StatefulWidget 缓存 | 无缓存每次都 rebuild | workspace 不变时完全复用子树,减少 50%+ 无谓重建 | +| ConsultBloc 提升至 ShellRoute | 跟随页面创建/销毁 | 跨页面保持咨询状态,避免退出页面丢数据 | +| AppData 单次创建 + Section 按 workspace 缓存 | 每次切换 workspace 重新加载 | 导航三栏数据(projects/workspaces/sections)生命周期由 AppBloc 统一管理 | diff --git a/docs/dev/studio/packages.md b/docs/dev/studio/packages.md new file mode 100644 index 00000000..7e8d785f --- /dev/null +++ b/docs/dev/studio/packages.md @@ -0,0 +1,71 @@ +# 分包方案 + +## 当前问题 + +`lib/models/` 下 6 个领域混在同一目录,随新增领域持续膨胀;同时对应的 blocs、screens、views 也散落在各自目录中,领域边界模糊,跨 app 复用只能靠复制。 + +| 领域 | 文件 | 跨应用潜力 | +|------|------|-----------| +| 组织管理 | `models/org.dart` + `screens/org_screen.dart` | 中 — `qtcloud-hr` 可能需要 | +| 咨询 | `models/qtconsult.dart` + `blocs/consult_bloc.dart` + `screens/qtconsult_screen.dart` | 高 — 与 `qtconsult` 重叠 | +| 课堂 | `models/qtclass.dart` + `screens/qtclass_screen.dart` | 高 — 与 `qtclass` 重叠 | +| 思考 | `models/thinking.dart` + `screens/thinking_screen.dart` | 中 — `qtcloud-think` 可能需要 | +| 仪表盘 | `models/dashboard.dart` + screens + views | 低 — qtadmin 专属 | +| 导航结构 | `models/metadata.dart` | 低 — qtadmin 专属 | + +## 分包架构 + +按领域分包,每个包包含完整的领域层:模型、BLoC、页面、UI 组件、测试。 + +``` +src/studio/ +├── packages/ +│ ├── qtadmin-org/ ← 组织管理 +│ │ ├── lib/ +│ │ │ ├── org.dart (Freezed 模型) +│ │ │ └── src/ +│ │ │ ├── blocs/ (OrgBloc) +│ │ │ ├── screens/ (OrgScreen) +│ │ │ └── views/ (小组件) +│ │ └── test/ +│ ├── qtadmin-qtconsult/ ← 咨询 +│ │ ├── lib/ +│ │ │ ├── qtconsult.dart (Freezed 模型) +│ │ │ └── src/ +│ │ │ ├── blocs/ (ConsultBloc) +│ │ │ ├── screens/ (QtConsultScreen) +│ │ │ └── views/ +│ │ └── test/ +│ ├── qtadmin-qtclass/ ← 课堂 +│ │ └── ... +│ └── qtadmin-think/ ← 思考 +│ └── ... +├── lib/ +│ ├── models/ ← 仅保留 dashboard + metadata +│ ├── blocs/ ← 仅保留 AppBloc +│ ├── screens/ ← 仅保留 dashboard + 通用 screens +│ └── views/ ← 仅保留通用 UI 组件 +``` + +### 各包方案 + +| 领域 | 独立包 | 包含内容 | 复用目标 | +|------|-------|---------|---------| +| `qtconsult` | `packages/qtadmin-qtconsult` | 模型 + ConsultBloc + ConsultScreen + UI 组件 | `qtconsult` 项目,共享模型和业务逻辑 | +| `qtclass` | `packages/qtadmin-qtclass` | 模型 + QtClassScreen + UI 组件 | `qtclass` 项目,共享模型和业务逻辑 | +| `thinking` | `packages/qtadmin-think` | 模型 + ThinkingScreen | `qtcloud-think`,共享思考记录模型 | +| `org` | `packages/qtadmin-org` | 模型 + OrgScreen + UI 组件 | `qtcloud-hr`,共享组织架构模型 | +| `dashboard` | 留在主项目 | — | 专属聚合视图,无复用 | +| `metadata` | 留在主项目 | — | 导航配置,app 专属 | + +### 提取原则 + +每个包独立开发、独立测试、独立版本。提取节奏按需进行,不搞大版本重构: + +1. **先提取模型**(已完成)—— 解耦数据定义,获得立即的构建隔离 +2. **逐步迁移业务逻辑和 UI** —— 随需求稳定,逐个搬入包内 +3. **跨 app 复用前不强制** —— 等到第二个消费者出现时再补齐包内完整内容 + +## 与平台层的关系 + +通用项目模型(Board, BoardCard, Project)应从 pub.dev 引入 `quanttide_project`(来自 `packages/quanttide-project-toolkit`),不在 qtadmin 内重复定义。当通用模型无法满足管理后台需求时,在对应私有包内做适配,不修改通用模型。 diff --git a/docs/dev/studio/refactor.md b/docs/dev/studio/refactor.md new file mode 100644 index 00000000..d4525cfe --- /dev/null +++ b/docs/dev/studio/refactor.md @@ -0,0 +1,38 @@ +# Studio 技术债务评估 + +使用 SQFD 框架评估。评级:**低**(2026-05-09)。 + +## 评估维度 + +| 维度 | 权重 | 评级 | +|:-----|:----:|:----:| +| 测试覆盖 | 25% | 低 | +| 架构耦合 | 25% | 低 | +| 错误韧性 | 20% | 低 | +| 工具链一致 | 10% | 低 | +| 可移植性 | 10% | 低 | +| 可维护性 | 10% | 低 | + +全部六项 **低**,综合评级 **低**。 + +## 测试覆盖 + +166 tests,全分层 100%: + +| 层 | 文件 | 用例 | +|:---|:----:|:----:| +| 模型 | 6/6 | 78 | +| views | 8/8 | 13 | +| screens | 7/7 | 47 | +| sources | 3/3 | 9 | +| blocs | 2/2 | 9 | +| 导航 | 1/1 | 10 | + +## 变化总结 + +| 阶段 | 评级 | 主要工作 | +|:-----|:----:|:---------| +| 初始 | 高 | 手写 fromJson、setState 遍地、6 个重复 loader、零测试 | +| 第一轮 | 高→中 | freezed 迁移、数据源抽象、BLoC 引入、死代码清理 | +| 第二轮 | 中→低 | 加载失败防护、Web 兼容、全量测试、CI + pre-commit | +| P0-P2 | 低维持 | 纯 GoRouter、路由表合并、Section 缓存、ConsultBloc 生命周期提升 | diff --git a/docs/drd/README.md b/docs/drd/README.md new file mode 100644 index 00000000..064ccb14 --- /dev/null +++ b/docs/drd/README.md @@ -0,0 +1,14 @@ +# DRD + +数据 schema 规范,与实现文档分离。 + +开发者文档关注"代码怎么用这些数据",DRD 关注"数据长什么样"。数据契约独立于实现,可以被不同模块/语言引用。 + +## 文件 + +- `metadata.json` — 导航元数据 schema +- `dashboard.json` — 仪表盘数据模型 schema +- `qtclass.json` — 量潮课堂数据模型 schema +- `thinking.json` — 思考页面数据模型 schema +- `qtconsult.json` — 咨询模块数据模型 schema +- `org.json` — 组织管理数据模型 schema diff --git a/docs/drd/dashboard.md b/docs/drd/dashboard.md new file mode 100644 index 00000000..d9c13e0f --- /dev/null +++ b/docs/drd/dashboard.md @@ -0,0 +1,67 @@ +# DashboardData Schema + +## Fixture 路径 + +`assets/fixtures/{workspace}/dashboard.json` + +## DashboardData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `businessUnits` | object[] | 是 | 业务线列表,展示在仪表盘上方 | +| `functionCards` | object[] | 是 | 职能线卡片列表,展示在仪表盘下方 | + +## BusinessUnitData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | — | 业务线名称 | +| `tag` | string | 是 | — | 标签(如 `"主营"`、`"孵化中"`) | +| `isPrimary` | bool | 否 | `false` | 是否主营 | +| `screenType` | string | 否 | — | 覆盖 `pageType`,跳转到独立页面(如 `"consulting"`、`"thinking"`) | +| `consultSource` | string | 否 | — | 咨询数据来源,`"customer"` / `"internal"` | +| `decisions` | object[] | 是 | — | 待决策事项列表 | +| `emptyMessage` | string | 否 | — | 无决策事项时的占位文案 | + +## DecisionData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `fromPerson` | string | 是 | — | 发起人 | +| `deadline` | string | 是 | — | 截止时间描述 | +| `title` | string | 是 | — | 决策标题 | +| `context` | string | 是 | — | 上下文背景 | +| `teamAdvice` | string | 是 | — | 团队建议 | +| `isUrgent` | bool | 否 | `false` | 是否紧急 | +| `actions` | object[] | 是 | — | 可执行的决策操作 | + +## DecisionAction + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `label` | string | 是 | — | 操作文字 | +| `isPrimary` | bool | 否 | `false` | 是否为主要操作按钮 | + +## FuncCardData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | — | 职能名称 | +| `metrics` | object[] | 是 | — | 指标列表(2-3 个) | +| `trend` | object | 否 | — | 趋势状态 | +| `warning` | string | 否 | — | 预警文案 | +| `isWarning` | bool | 否 | `false` | 是否显示预警态 | + +## MetricData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `label` | string | 是 | 指标名 | +| `value` | string | 是 | 指标值 | + +## TrendData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `text` | string | 是 | — | 趋势描述 | +| `direction` | string | 否 | `"flat"` | `"up"` / `"down"` / `"flat"` | diff --git a/docs/drd/metadata.md b/docs/drd/metadata.md new file mode 100644 index 00000000..30d7e709 --- /dev/null +++ b/docs/drd/metadata.md @@ -0,0 +1,98 @@ +# metadata.json Schema + +## 根 metadata.json + +| 路径 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `workspaces` | array | 是 | 所有可用 Workspace 工作空间 | +| `workspaces[].id` | string | 是 | 逻辑 ID,不依赖目录名 | +| `workspaces[].name` | string | 是 | Workspace 显示名,出现在 `WorkspaceSwitcher` | +| `workspaces[].icon` | string | 是 | 图标名 | +| `workspaces[].dir` | string | 是 | fixture 子目录名,解耦 ID 和路径 | +| `sections` | array | 是 | 导航段定义 | +| `sections[].id` | string | 是 | 段标识符,Workspace 按 id 引用 | +| `sections[].dividerBefore` | boolean | 是 | 该段前是否渲染分隔线 | + +```json +{ + "workspaces": [ + { "id": "internal", "name": "量潮创始人", "icon": "person_outline", "dir": "founder" }, + { "id": "customer", "name": "量潮科技", "icon": "business_outlined", "dir": "company" } + ], + "sections": [ + { "id": "dashboard", "dividerBefore": false }, + { "id": "business", "dividerBefore": true }, + { "id": "function", "dividerBefore": true } + ] +} +``` + +→ `dashboard` 段无上分隔线,`business` 和 `function` 段前有分隔线。 + +## 每 Workspace metadata.json + +`assets/fixtures/{dir}/metadata.json` + +| 路径 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `sections` | array | 是 | 该 Workspace 引用的导航段 | +| `sections[].id` | string | 是 | 引用根的段 id | +| `sections[].items` | string[] | 是 | 导航项 name 列表,通过 `RouteConfig.find(name)` 解析路由 | + +items 为纯字符串,对应 `RouteConfig` 中定义的 `id`。label、icon、screenType 均在 Dart 代码的 `RouteConfig` 中集中管理。 + +founder 引用 `dashboard` + `business` 两个段: + +```json +{ + "sections": [ + { "id": "dashboard", "items": ["dashboard"] }, + { "id": "business", "items": ["thinking", "writing"] } + ] +} +``` + +→ 侧边栏: 仪表盘 | 分隔线 | 思考 · 写作 + +company 引用全部三个段: + +```json +{ + "sections": [ + { "id": "dashboard", "items": ["dashboard"] }, + { "id": "business", "items": ["data", "classroom", "consulting", "cloud"] }, + { "id": "function", "items": ["hr", "finance", "org", "strategy", "media"] } + ] +} +``` + +→ 侧边栏: 仪表盘 | 分隔线 | 数据·课堂·咨询·云 | 分隔线 | 人力·财务·组织·战略·新媒体 + +## RouteConfig 路由表 + +路由定义集中在 `lib/route_config.dart` 的 `RouteConfig.all` 列表中。 + +| id | label | icon | screenType | 依赖数据 | +|---|---|---|---|---| +| `dashboard` | 仪表盘 | `today_outlined` | `dashboard` | dashboard.json | +| `thinking` | 思考 | `psychology_outlined` | `thinking` | thinking.json | +| `writing` | 写作 | `edit_outlined` | `writing` | 无(占位) | +| `consulting` | 量潮咨询 | `support_agent_outlined` | `consulting` | qtconsult.json | +| `classroom` | 量潮课堂 | `school_outlined` | `classroom` | qtclass.json | +| `org` | 组织管理 | `account_tree_outlined` | `org` | org.json | +| `data` | 量潮数据 | `storage_outlined` | `business_detail` | dashboard.json → businessUnits | +| `cloud` | 量潮云 | `cloud_outlined` | `business_detail` | dashboard.json → businessUnits | +| `hr` | 人力资源 | `people_outline` | `function_detail` | dashboard.json → functionCards | +| `finance` | 财务管理 | `account_balance_outlined` | `function_detail` | dashboard.json → functionCards | +| `strategy` | 战略管理 | `track_changes_outlined` | `function_detail` | dashboard.json → functionCards | +| `media` | 新媒体 | `campaign_outlined` | `function_detail` | dashboard.json → functionCards | + +## 可用图标 + +`person_outline` `business_outlined` `today_outlined` `storage_outlined` +`school_outlined` `support_agent_outlined` `cloud_outlined` +`psychology_outlined` `edit_outlined` `people_outline` +`account_balance_outlined` `account_tree_outlined` +`track_changes_outlined` `campaign_outlined` + +未识别的降级为 `Icons.circle_outlined`。 diff --git a/docs/drd/org.md b/docs/drd/org.md new file mode 100644 index 00000000..ce454c2e --- /dev/null +++ b/docs/drd/org.md @@ -0,0 +1,76 @@ +# OrgDashboardData Schema + +## Fixture 路径 + +`assets/fixtures/{workspace}/org.json` + +## OrgDashboardData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `institutions` | object[] | 是 | 所有组织机构 | +| `representatives` | object[] | 是 | 所有代表/成员 | +| `ranks` | object[] | 是 | 职级体系 | +| `promotions` | object[] | 是 | 职级晋升记录 | + +## OrgInstitutionData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `id` | string | 是 | — | 机构唯一标识 | +| `name` | string | 是 | — | 机构显示名称 | +| `parentId` | string | 否 | `""` | 父机构 id,空串表示顶级 | +| `level` | int | 否 | `0` | 层级深度(顶级为 0) | +| `status` | string | 是 | — | `"normal"` / `"warning"` / `"overdue"` | +| `lastMeetingDate` | string | 否 | — | 最近会议时间描述(如 `"3天前"`) | +| `nextMeetingDate` | string | 否 | — | 下次会议时间描述(如 `"5天后"`) | +| `expectedFrequency` | string | 否 | `""` | 预期会议频率(如 `"每周一次"`) | +| `memberIds` | string[] | 否 | `[]` | 机构成员 id 列表 | +| `pendingProposalCount` | int | 否 | `0` | 待处理提案数 | + +## OrgRepresentativeData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `id` | string | 是 | — | 代表唯一标识 | +| `name` | string | 是 | — | 代表姓名 | +| `institutionIds` | string[] | 是 | — | 所属机构 id 列表(多对多) | +| `rank` | string | 是 | — | 职级(如 `"M1"`) | +| `term` | string | 否 | `""` | 任期(如 `"2026Q1-Q2"`) | +| `attendanceRate` | number | 否 | `0` | 出勤率(0-100) | +| `proposalCount` | int | 否 | `0` | 提案数 | +| `voteRate` | number | 否 | `0` | 投票参与率(0-100) | +| `objectionCount` | int | 否 | `0` | 反对票数 | +| `tier` | string | 是 | — | 绩效等第:`"green"` / `"yellow"` / `"red"` | +| `recentVotes` | object[] | 否 | `[]` | 最近投票记录 | + +## OrgMeetingData(recentVotes 项) + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `id` | string | 是 | — | 会议唯一标识 | +| `institutionId` | string | 是 | — | 所属机构 id | +| `date` | string | 是 | — | 会议日期(如 `"2026-05-06"`) | +| `title` | string | 是 | — | 会议标题 | +| `agendaItems` | string[] | 否 | `[]` | 议程项列表 | +| `attendeeCount` | int | 否 | `0` | 实际出席人数 | +| `totalMemberCount` | int | 否 | `0` | 应到人数 | + +## OrgRankData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | — | 职级名称(如 `"M1"`) | +| `isManagement` | bool | 否 | `false` | 是否为管理岗 | +| `headCount` | int | 否 | `0` | 当前在职人数 | + +## OrgPromotionData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `id` | string | 是 | — | 晋升记录唯一标识 | +| `personName` | string | 是 | — | 晋升人员姓名 | +| `fromRank` | string | 是 | — | 晋升前职级 | +| `toRank` | string | 是 | — | 晋升后职级 | +| `date` | string | 是 | — | 晋升日期(如 `"2026-04-01"`) | +| `isCrossTrack` | bool | 否 | `false` | 是否跨序列晋升 | diff --git a/docs/drd/qtclass.md b/docs/drd/qtclass.md new file mode 100644 index 00000000..ba286824 --- /dev/null +++ b/docs/drd/qtclass.md @@ -0,0 +1,33 @@ +# QtClassData Schema + +## Fixture 路径 + +`assets/fixtures/company/qtclass.json` + +## QtClassData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `components` | object[] | 是 | 量潮课堂的组成部分列表 | + +## QtClassComponentData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `type` | string | 是 | — | `"schoolEnterprise"` / `"trainingBase"` / `"internalTeaching"` / `"oneOnOne"` | +| `name` | string | 是 | — | 组件显示名 | +| `description` | string | 是 | — | 描述 | +| `status` | string | 是 | — | 状态标签(如 `"进行中"`、`"运营中"`、`"常态化"`、`"可预约"`) | +| `studentCount` | number | 是 | — | 学员数 | +| `projectCount` | number | 是 | — | 项目数 | +| `deadline` | string | 否 | `null` | 截止时间描述 | +| `highlights` | string[] | 是 | — | 亮点列表 | + +## ComponentType 枚举 + +| 值 | 含义 | 图标 | 颜色 | +|---|---|---|---| +| `schoolEnterprise` | 校企合作 | `business_outlined` | `#1565C0` | +| `trainingBase` | 实训基地 | `school_outlined` | `#2E7D32` | +| `internalTeaching` | 内部教学 | `group_outlined` | `#6A1B9A` | +| `oneOnOne` | 一对一 | `person_outline` | `#E65100` | diff --git a/docs/drd/qtconsult.md b/docs/drd/qtconsult.md new file mode 100644 index 00000000..8d599b72 --- /dev/null +++ b/docs/drd/qtconsult.md @@ -0,0 +1,71 @@ +# QtConsultData Schema + +## Fixture 路径 + +`assets/fixtures/{workspace}/qtconsult.json` + +## QtConsultData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `workspace` | string | 否 | `"customer"` / `"internal"`,默认 `"customer"` | +| `projectName` | string | 是 | 项目名称 | +| `phase` | string | 是 | 当前阶段 | +| `industry` | string | 是 | 行业 | +| `scale` | string | 是 | 团队规模描述 | +| `maturity` | string | 是 | 数字化成熟度 | +| `strategyGoal` | string | 是 | 策略目标 | +| `strategyInsight` | string | 是 | 策略洞察 | +| `strategySteps` | string[] | 是 | 策略步骤 | +| `riskNote` | string | 是 | 风险备注 | +| `discoveries` | object[] | 是 | 发现清单 | +| `communications` | object[] | 否 | 沟通记录,默认 `[]` | +| `revisions` | object[] | 是 | 策略修正历史 | +| `stakeholders` | object[] | 是 | 决策链路干系人 | + +## DiscoveryData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `id` | string | 是 | — | PK | +| `text` | string | 是 | — | 描述具体事实 | +| `type` | string | 是 | — | `"risk"` / `"concern"` / `"opportunity"` / `"neutral"` | +| `status` | string | 否 | `"pending"` | `"pending"` → `"confirmed"` / `"dismissed"` | +| `source` | string | 是 | — | 来源会议 | +| `date` | string | 是 | — | 创建日期 | +| `linkedToStrategy` | bool | 否 | `false` | 是否已链接到策略 | + +## StrategyRevisionData + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `id` | string | 是 | — | PK | +| `date` | string | 是 | — | 修正日期 | +| `reason` | string | 是 | — | 修正原因 | +| `relatedDiscoveryId` | string? | 否 | `null` | FK → DiscoveryData.id | +| `isReviewed` | bool | 否 | `false` | 是否已审视确认 | + +## CommunicationData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `id` | string | 是 | PK | +| `title` | string | 是 | 标题 | +| `date` | string | 是 | 日期 | +| `summary` | string | 是 | 摘要 | + +## StakeholderData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `id` | string | 是 | PK | +| `name` | string | 是 | 姓名 | +| `role` | string | 是 | 角色 | +| `stance` | string | 是 | `"support"` / `"neutral"` / `"oppose"` | +| `concern` | string | 是 | 核心关切 | +| `detail` | string | 是 | 补充说明 | + +## WorkspaceType + +`"customer"` — 对外交付,数据来源于客户沟通 +`"internal"` — 自我诊断,数据来源于量潮云 diff --git a/docs/drd/thinking.md b/docs/drd/thinking.md new file mode 100644 index 00000000..a2bf776c --- /dev/null +++ b/docs/drd/thinking.md @@ -0,0 +1,60 @@ +# ThinkingData Schema + +## Fixture 路径 + +`assets/fixtures/founder/thinking.json` + +## ThinkingData + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `title` | string | 是 | 页面主标题 | +| `subtitle` | string | 是 | 副标题 | +| `period` | string | 是 | 周期概述文案 | +| `awarenessSection` | object | 是 | 情境意识段落配置 | +| `awarenessSection.label` | string | 是 | 段落标签名 | +| `awarenessSection.icon` | string | 是 | 段落图标名 | +| `awarenessSection.color` | string | 是 | 段落主题色 hex | +| `stages` | object[] | 是 | 认知阶段列表 | +| `emotions` | object[] | 是 | 情绪数据列表 | +| `emotionNote` | string | 是 | 情绪分析说明 | +| `insightSection` | object | 是 | 心智模型段落配置 | +| `insightSection.label` | string | 是 | 段落标签名 | +| `insightSection.icon` | string | 是 | 段落图标名 | +| `insightSection.color` | string | 是 | 段落主题色 hex | +| `insights` | object[] | 是 | 心智洞察列表 | +| `closing` | object | 是 | 结尾总结 | + +## ThinkingStage + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `icon` | string | 是 | 图标名 | +| `title` | string | 是 | 阶段标题 | +| `subtitle` | string | 是 | 副标题 | +| `points` | string[] | 是 | 要点列表 | +| `color` | string | 是 | 主题色 hex | + +## ThinkingEmotion + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `label` | string | 是 | 情绪名称 | +| `value` | string | 是 | 统计值(如 `"450次"`) | +| `color` | string | 是 | 主题色 hex | + +## ThinkingInsight + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `icon` | string | 是 | 图标名 | +| `title` | string | 是 | 洞察标题 | +| `description` | string | 是 | 洞察描述 | + +## ThinkingClosing + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `title` | string | 是 | 总结标题 | +| `description` | string | 是 | 总结描述 | +| `quote` | string | 是 | 金句引用 | diff --git a/docs/ixd/navigation.md b/docs/ixd/navigation.md new file mode 100644 index 00000000..dd15a1e9 --- /dev/null +++ b/docs/ixd/navigation.md @@ -0,0 +1,54 @@ +# 导航结构规范 + +## 布局 + +导航项由各Workspace工作空间的 PanoramaData 驱动,不同Workspace工作空间展示不同内容: + +### 公司(量潮科技) + +``` +┌────────────┐ +│ Workspace工作空间切换器 │ +├────────────┤ +│ 全景图 │ +├────────────┤ +│ 量潮数据 │ 业务线(businessUnits → 通用/咨询类型) +│ 量潮课堂 │ +│ 量潮咨询 │ +│ 量潮云 │ +├────────────┤ +│ 人力资源 │ 职能线(functionCards) +│ 财务管理 │ +│ 组织管理 │ +│ 战略管理 │ +│ 新媒体 │ +├────────────┤ +│ 空白占位 │ +└────────────┘ +``` + +### 创始人(量潮创始人) + +``` +┌────────────┐ +│ Workspace工作空间切换器 │ +├────────────┤ +│ 全景图 │ +├────────────┤ +│ 思考 │ 个性工具(businessUnits → thinking/writing 类型) +│ 写作 │ +├────────────┤ +│ 空白占位 │ +└────────────┘ +``` + +## 设计规则 + +- **数据驱动**:导航项由 PanoramaData 的 `businessUnits` 和 `functionCards` 动态生成 +- **所有Workspace工作空间共享同一套代码**,差异仅来自 fixture 数据 +- **仅两个区域**:业务线(businessUnits)和职能线(functionCards),不因特殊模块新增区域 +- **`screenType` 决定页面类型**: + - `detail` → `BusinessDetailScreen` + - `consulting` → `QtConsultScreen` + - `thinking` → `ThinkingScreen` + - `writing` → 占位(即将上线) diff --git a/docs/ixd/panorama.md b/docs/ixd/panorama.md new file mode 100644 index 00000000..ed1a5868 --- /dev/null +++ b/docs/ixd/panorama.md @@ -0,0 +1,111 @@ +# 全景图页面 + +## 页面布局 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 量潮科技 2026年5月2日 · 星期六 │ +├──────────────────────────────────────────────────────────────┤ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 量潮数据 │ │ 量潮课堂 │ │ 量潮云 │ │ +│ │ (主营) │ │ (主营) │ │ (孵化中) │ │ +│ │ │ │ │ │ │ │ +│ │ 决策卡 │ │ 决策卡 │ │ 暂无待 │ │ +│ │ 决策卡 │ │ (超期⚠) │ │ 决策事项 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +├──────────────────────────────────────────────────────────────┤ +│ 人力 │ 财务 │ 组织 │ 战略 │ 新媒体 │ +│ 无异常│ 无预警│ 委托率 │ 无阻塞│ 稳定 │ +│ │ │ ↓5%⚠ │ │ │ +├──────────────────────────────────────────────────────────────┤ +│ 其余 5 位成员无需你介入 · 今日无待审批报销 │ +└──────────────────────────────────────────────────────────────┘ +``` + +备注:组件内容以下面的描述为准。 + +## 组件设计 + +> **组件成熟度**:每个组件标注当前阶段——`[已定型]` 定位明确可直接使用,`[待验证]` 逻辑可行需在实践中校验,`[试验中]` 原型阶段会随认知变化而调整。 + +### 业务卡片 [待验证] + +业务名称:[代号/代称] + +阶段状态:验证期 / 爬坡期 / 稳态运营 / 转型决策中 + +一句话定位:(我们为谁解决什么问题,当前边界在哪) + +当前现状: + +· 关键假设验证到什么程度(证真/证伪/待判) +· 最近一个周期最突出的变化(一句话描述趋势,不写数字) + +最大杠杆点: + +· 目前制约/撬动这条线的关键因子是什么(选一个写) + +下一步路径转向: + +· 接下来6个月逻辑上要发生什么转向(从A逻辑转向B逻辑,不是任务清单) +· 等待什么信号来判断转向时机 + +资源匹配度:偏紧 / 适中 / 有冗余可扩张 + +备注:(可留空,偶尔记异常观察) + + +## 职能卡片 [已定型] + +职能名称:[代号/代称] + +服务对象:主要支撑哪条业务线(填业务代号) + +当前承载力: + +· 当前能支撑的业务量级描述(抽象,如“可承载现有量,下阶段峰值有缺口”) + +能力代差: + +· 这块能力目前处在什么水平:补课期 / 够用 / 小幅领先 / 可反向输出 + +演进方向: + +· 接下来6个月核心建设(比如“从人工经验转为规则引擎”) +· 要消除的错位(能力发展和业务需求之间哪里有缝隙) + +对业务的角色:当前是瓶颈 / 持平 / 加速器 + +依赖与风险:关键依赖什么(人/工具/外部),有无单点风险 + +备注: + +--- + +## 决策卡片 [试验中] + +> 基于 BRD 场景三设计,当前为原型格式,需在实践中验证卡片字段是否覆盖了关键回溯场景 + +决策名称:[代号/代称] + +在什么情境下做的: + +· 当时项目进度、财务状态、可选方案概要 +· 触发这个决策的直接原因 + +谁参与了: + +· 参与人列表及每个人当时的输入/判断 + +选了哪个方案: + +· 选了的方案和没选的理由 +· 当时判断的核心依据 + +结果怎么样: + +· 事后回填的结论标签(正确 / 有偏差 / 错误) +· 偏差的原因分析(如果选择了"有偏差"或"错误") + +备注:(可留空,记录后续观察或关联决策) + diff --git a/docs/ixd/qtconsult.md b/docs/ixd/qtconsult.md new file mode 100644 index 00000000..95dc9b48 --- /dev/null +++ b/docs/ixd/qtconsult.md @@ -0,0 +1,53 @@ +# 量潮咨询页面 + +## 布局 + +贯穿整个咨询周期,左右联动:左边是对客户的认知,右边是对应的策略。 + +``` +┌─────────────── 信息看板 ───────────────┬─────────────── 策略看板 ───────────────┐ +│ │ │ +│ ┌─ 客户画像 ───────────────────────┐ │ ┌─ 战略诉求 ───────────────────────┐ │ +│ │ 行业:XX 规模:XX 阶段:XX │ │ │ 客户想通过数字化解决什么问题 │ │ +│ │ 数字化成熟度:L2(数据采集阶段) │ │ │ 短期目标 → 中期目标 → 长期愿景 │ │ +│ └──────────────────────────────────┘ │ └──────────────────────────────────┘ │ +│ │ │ +│ ┌─ 发现清单 ───────────────────────┐ │ ┌─ 切入策略 ───────────────────────┐ │ +│ │ · 数据分散在3个系统,无法打通 │ │ │ 从哪个痛点切入 → 分几步走 │ │ +│ │ · 管理层有数字化意愿但中层抗拒 │ │ │ 第一步做什么 → 预期成果 │ │ +│ │ · IT部门只有2人,运维都吃力 │ │ │ 风险点 → 备选方案 │ │ +│ │ + 添加新发现 │ │ └──────────────────────────────────┘ │ +│ └──────────────────────────────────┘ │ │ +│ │ ┌─ 决策链路 ───────────────────────┐ │ +│ ┌─ 沟通记录 ───────────────────────┐ │ │ 关键人 │ 立场 │ 关注点 │策略 │ │ +│ │ 5/14 需求调研会 · 纪要看全文 │ │ │ CEO │ 支持 │ 降本 │ 推ROI │ │ +│ │ 5/10 初次接触 · 纪要看全文 │ │ │ CIO │ 中立 │ 技术选 │ 做POC │ │ +│ └──────────────────────────────────┘ │ │ 财务 │ 反对 │ 预算 │ 分期 │ │ +│ │ └──────────────────────────────────┘ │ +│ 信息看板回答:客户现在是什么情况 │ 策略看板回答:我们打算怎么应对 │ +└────────────────────────────────────────┴────────────────────────────────────────┘ +``` + +## 设计原则 + +1. **一条发现 → 对应一条策略调整**。左边加了"中层抗拒",右边就要确认策略里有没有应对中层的内容 +2. **贯穿全程不退场**。签约前用这套结构做评估,交付中用这套结构做调整,结束后用这套结构做复盘 +3. **不存"知道了但不行动"的信息**。左边每一条发现都必须能回答"所以呢"——如果一条信息不会改变右边的策略,就不值得记 + +## 组件 + +### 信息看板(左) + +**客户画像** — 不变的基础信息:行业、规模、阶段、数字化成熟度。一次性填好,后续只更新成熟度评估。 + +**发现清单** — 对客户业务的核心观察,每一条是一句话。按时间倒序排列,最新发现置顶。是咨询过程中最重要的产出物——发现的质量决定了策略的质量。 + +**沟通记录** — 每次客户接触的纪要索引,只显示日期+标题,详情点开看。作用是让"发现清单"有据可查。 + +### 策略看板(右) + +**战略诉求** — 客户想达成的目标。随时间推移会逐渐清晰或变化。咨询团队需要持续校准——客户说的"要数字化转型"和实际想要的往往不是一回事。 + +**切入策略** — 从哪个点开始、分几步走、每一步做什么。发现清单更新时,策略可能需要调整(发现"中层抗拒" → 增加"中层动员"步骤)。 + +**决策链路** — 谁说了算、谁在犹豫、谁反对、各自关心什么。每条记录包含对该人的应对策略。发现清单中关于人的发现会直接反映在这里。 diff --git a/docs/myst.yml b/docs/myst.yml new file mode 100644 index 00000000..03607043 --- /dev/null +++ b/docs/myst.yml @@ -0,0 +1,19 @@ +# MyST configuration for qtadmin project docs +version: 1 +project: + title: qtadmin + description: 量潮科技内部第二大脑 + keywords: + - 量潮科技 + - qtadmin + - 内部知识库 + authors: + - name: 量潮科技 + github: https://github.com/quanttide/qtadmin + license: Proprietary + toc: + - file: user-guide/asset.md +site: + template: book-theme + options: + folders: false diff --git a/docs/ops/studio.md b/docs/ops/studio.md new file mode 100644 index 00000000..dcb726d7 --- /dev/null +++ b/docs/ops/studio.md @@ -0,0 +1,60 @@ +# Studio 运维文档 + +## 环境要求 + +- Flutter SDK 3.x(stable channel) +- Dart SDK 3.8+ + +## 开发 + +```bash +# 首次 +cd src/studio +flutter pub get +git config core.hooksPath .githooks # 激活 pre-commit 检查 + +# 日常 +flutter run -d linux # Linux 桌面 +flutter run -d chrome # Web +dart analyze lib/ test/ # 静态检查 +flutter test # 运行测试 + +# 代码生成(freezed) +dart run build_runner build # 修改模型后重新生成 +``` + +## 测试 + +166 tests,分层覆盖: + +| 层 | 文件数 | 覆盖 | +|:---|:------:|:----:| +| 模型 | 6/6 | 100% | +| sources | 3/3 | 100% | +| blocs | 2/2 | 100% | +| screens | 7/7 | 100% | +| views | 8/8 | 100% | + +## CI + +`.github/workflows/studio.yml`:push/PR 触发,`src/studio/**` 路径过滤。 + +- `flutter pub get` +- `dart analyze lib/ test/` +- `flutter test` + +## pre-commit + +`.githooks/pre-commit`:提交前自动运行 `dart analyze`,通过才允许提交。 + +激活:`git config core.hooksPath .githooks` + +## Web 构建 + +```bash +flutter build web +``` + +输出在 `build/web/`。 + + diff --git a/docs/prd/index.md b/docs/prd/index.md new file mode 100644 index 00000000..720df40f --- /dev/null +++ b/docs/prd/index.md @@ -0,0 +1,123 @@ +# 产品需求文档 + +## 设计哲学 + +任何一个人打开看板,看到的都是一张完整的公司图景。区别只在于:不同角色看到的信息粒度不同、能操作的范围不同。但每个人都知道公司在发生什么。 + +> **文档状态**:整体 `[待验证]`。通用骨架已定型,角色切片为 1.0 初版——需在实际使用中迭代。 + +## 主体架构 + +系统分为两个并行的主体空间,分别对应"造钟"与"报时": + +### 量潮创始人(核心主体 / 量潮控股) + +代理的是**量潮控股**的角色——控股公司不直接经营业务,而是通过持有和管理子公司的股权来实现价值。量潮创始人就是这个"控股主体"的数字神经系统。 + +不是"CEO 个人",而是**你 + 直接贡献你体系的人**这个集合。使命是构建和维护系统本身——方法论、工具链、认知框架、工作手册。侧重点是"造钟"。 + +导航空间: +- **全景图**:核心主体的整体看板,包括方法论建设进度、工具链健康度、认知资产沉淀等 +- **思考**:认知建构与思维演进报告,跟踪核心主体的思维模式演变 +- **写作**:体系化输出的产物——文档、手册、PRD、架构设计 + +这个空间里没有具体的"业务线",因为控股公司不直接对外交付。业务经营由被控股的子公司(量潮科技)负责。 + +### 量潮科技(业务经营主体) + +对外交付的组织实体,负责具体产品和服务。侧重点是"报时"。 + +导航空间: +- **全景图**:公司经营看板,包含业务线决策事项和职能线运营指标 +- **量潮数据 / 量潮课堂 / 量潮咨询 / 量潮云**:各业务线的详情与决策面板 + +### 两个主体的关系 + +量潮创始人决定了量潮科技的上限——方法论和工具链的质量直接影响业务交付效率。但日常运营中,两个空间的关注点和操作节奏不同:核心主体在"打磨钟",业务主体在"报时"。 + +### 内部观察循环 + +组织内存在一个关键的反馈机制:**内部观察循环**。 + +凡是服务型的业务线(咨询、课堂),都可以把服务指向组织自身,创造一个独立观察者视角。量潮云是公司对自己的陈述——项目数据、财务指标、产能状态,是公司眼中的自己。 + +参与内部观察的主体可以有两个层级: + +| 主体 | 可用服务 | 观察什么 | +|------|----------|----------| +| 创始人 | 咨询、课堂 | 公司的战略/认知/团队能力 | +| 量潮科技(组织) | 咨询、课堂 | 自身的运营效率/能力短板 | + +创始人和量潮科技分别用量潮咨询和量潮课堂创建指向自己的项目,以独立观察者的身份审视量潮云提供的数据,形成策略调整建议。这与对外交付是同一套交互框架,只是数据源和观察立场不同。 + +两个视角的偏差——公司认为自己是什么样的,与独立观察者看到它是什么样的——就是成长空间。偏差越大,调整需求越清晰;偏差趋近于零,说明观察者视角已高度内化。 + +架构上这意味着: + +- **量潮咨询和量潮课堂的代码层面需支持多Workspace工作空间**:客户项目和内部项目共享同一套交互框架,但数据源和访问权限隔离 +- **量潮云作为内观平台**:提供公司运营的原始数据,不掺杂外部判断 +- **内部Workspace工作空间作为外观平台**:用量潮咨询/量潮课堂的方法论审视量潮云的数据,产生独立的观察判断 + +## 通用骨架 + +| 看全图 | → | 做操作 | → | 确认更新 | +|--------|---|--------|---|---------| + +### 看全图 + +- 打开看板,看到当前主体的概览 +- 在业务主体下,顶部是公司名和日期,这是每个人的工作起点 +- 下方是业务线全貌:量潮数据、量潮课堂、量潮云,三个单元并排或依次排列。每个人都能看到公司在靠什么赚钱、有哪些业务在运转 +- 每个业务单元下面,能看到当前进行中的事项:哪些项目在推进、哪些在等待决策、哪些已超期。区别只是:你能否看到详情,以及你是否可以操作 +- 继续往下滑是职能线全貌:人力资源、财务管理、组织管理、战略管理、新媒体运营,五个模块一个不少。每个人都能看到公司这台机器是否正常运转 +- 底部是全局确认信息:今日有无待审批事项、全员周报是否提交、有无异常人员变动。这一行是每个人的"今天可以翻篇"信号 + +### 做操作 + +- 在自己权限范围内的事项上,可以直接操作 +- 超出自己权限但需要上级介入的事项,有一个统一的"提请决策"入口,把事项和判断一起报给有权限的人 +- 提请之后,这条事项不会消失,而是状态变成"等待某人决策",提请人能看到进展,不会被黑洞吞没 + +### 确认更新 + +- 自己操作过的事项,状态已更新 +- 自己提请的事项,看到已被处理或仍在等待 +- 全局确认信息没有新的警报 +- 关掉看板,知道自己在组织这张地图上今天的位置和贡献 + + +## 角色切片 [待验证] + +> 以下角色均归属于量潮科技主体。量潮创始人主体的成员(你 + 直接贡献体系的人)属于另一套权限体系,暂未切片。 + +### 李四维(量潮数据执行成员,无管理权限) + +**看全图**:和所有人看到一样的全图结构,但操作按钮全部不可点。能看到牛津项目在推进中,华为项目在等待CEO拍板,杭电实训超期。 + +**做操作**:发现牛津项目的新增维度需求还没人提请决策,他可以点"提醒负责人"通知陈小明。不直接操作,但可以推动事情不被遗漏。 + +**确认更新**:提醒已发出。看到今天公司整体运转正常。 + +### 陈小明(量潮数据业务负责人) + +**看全图**:打开看板,顶部是量潮科技和今天的日期。业务线三个单元都在,但量潮数据是他视线的主场——卡片展开、项目可见、决策可点。量潮课堂和量潮云只显示条目数和状态概要。职能线五个模块显示概要数据。 + +**做操作**:华为项目"接不接"在他权限内无法定——涉及跨业务线资源占用,需要提请CEO。他点击"提请CEO决策",填入自己的判断("倾向接,能维持老客户"),提交。这条决策从自己的待处理列表移到"等待CEO决策"栏,卡片状态变为橙色等待标记。 + +**确认更新**:牛津项目李四维已经处理完毕,华为项目显示"等待CEO决策"。今天量潮数据这条线上没有其他阻塞项。他关掉看板。 + +### 小张(秘书处,全局协调角色) + +**看全图**:和CEO看到的信息范围一样(全业务+全职能详情),但所有操作按钮不可点——是只读版的CEO视角。 + +**做操作**:发现量潮数据和量潮课堂同时有项目在争抢人力,但CEO还没看到这个交叉点。小张可以在两个业务单元之间添加一条"协调提醒",让CEO在拍板华为项目时能看到"注意:可能影响杭电工期"。 + +**确认更新**:协调提醒已标注。全局确认信息无异常。CEO拍完板后两条决策都显示已处理。 + +### CEO(量潮科技负责人) + +**看全图**:所有业务单元展开、所有职能模块展开。但今天只有三条需要拍的板,其余都在安静运转。 + +**做操作**:看到陈小明提请的华为项目,带他的判断。看到王老师提请的杭电延期,带他的建议。看到李四维提请的牛津需求,也带判断。三条读完,分别点批准/同意延期/同意加需求。拍完。 + +**确认更新**:三条决策全部变为"已处理"。往下滑,职能线组织管理模块标黄——决策委托率连续两月下降。这是今天唯一还需要想想的事,但不是今天必须解决的。底部写"其余成员无需介入,今日无待审批报销"。关掉看板。 diff --git a/docs/prd/org.md b/docs/prd/org.md new file mode 100644 index 00000000..3d41fa55 --- /dev/null +++ b/docs/prd/org.md @@ -0,0 +1,116 @@ +# 组织管理 PRD + +> 职能线模块之一,隶属量潮科技主体。 + +## 设计目标 + +将公司的章程性组织规则转化为可操作的日常管理工具: +- 组织架构不再是静态的架构图,而是**可更新的机构状态看板** +- 每个机构都有当前的运作状态、成员构成和待处理事项 +- 公司代表的权利可行使、义务可追溯 + +不做一个HR管理系统,也不做一个OA审批流。做一个**组织运行状态的实时看板**: +- 机构在运转吗(会议是否按期召开、决策是否形成) +- 代表在履职吗(参会率、提案数、表决记录) +- 职级在流动吗(晋升记录、培养路径) + +## 三层设计 + +### 第一层:机构管理 + +将章程中的两院制机构化为可管理的实体。每个机构维护以下信息: + +- **基本信息**:名称、层级、上级机构 +- **会议状态**:上次会议时间、下次会议时间(按章程频率自动推算)、逾期标记 +- **成员列表**:当前成员及角色(主持/参与) +- **待办事项**:需该机构决策的事项列表 + +按章程预置机构层级: + +``` +合伙人委员会(行使立宪权与司法权) + └── 书记处(统筹协调) + ├── 执行委员会(管理事项提案) + └── 技术委员会(技术事项提案) + ↑ (书记处汇总后提交) +公司代表大会(全体表决决策) +``` + +机构看板的状态规则: +- 距下次会议不足1天 → 标黄(待准备) +- 超过章程频率未开会 → 标红(逾期) +- 有提案待处理超过3天 → 该机构卡片标红 + +### 第二层:代表履职管理 + +将《公司代表章程》中的五项职权转化为可操作的功能,每项职权对应一个操作入口: + +| 职权 | 功能入口 | 触发条件 | +|------|----------|----------| +| 信息获取权 | "调取资料"按钮 | 代表随时可发起 | +| 提案权 | "提交议案"表单 | 代表实名发起 | +| 审议权 | "参与讨论"入口 | 会议中/有审议事项时 | +| 表决权 | "投票"面板 | 正式表决阶段 | +| 程序异议权 | "提出异议"入口 | 会议程序中 | + +每项职权的行使记录自动存入代表履职档案。 + +代表履职卡展示内容: +- **基本信息**:所属机构、职级、任期 +- **履职指标**:参会率、提案数、表决参与率、异议次数 +- **近期履职记录**:最近5次表决/提案/异议的时间与事项 + +红色预警规则: +- 连续3次缺席会议 → 履职卡标红 +- 连续2次未参与表决 → 履职卡标黄 +- 12个月内累计缺席超过章程规定次数 → 提示"触发免职程序" + +绿色动态: +- 成功提案被采纳 → 履职卡标绿(持续24小时) +- 代表依据程序异议权成功纠正程序错误 → 履职卡标绿 + +### 第三层:职级与晋升管理 + +将双通道职级体系转化为可追踪的流动看板。 + +当前职级分布概览: +- 专业序列(非M序列):人数 +- 管理序列(M序列):M1人数 / M2人数 +- 待晋升候选池:满足晋升条件的成员列表 + +晋升记录时间线: +- 每次晋升记录:人员、原职级、目标职级、生效日期、批准人 +- 按时间倒序排列,可筛选序列 + +晋升触发条件(章程规则数字化): +- 序列内直升 → 职级提升一级 +- 非M序列→M序列跨序列晋升 → 职级提升半级 + +## 组织全景图集成 + +> 在全景图的职能线中,组织管理模块只展示三样东西: + +1. **机构健康度**:有多少机构逾期未开会 → 点击跳转至机构看板 +2. **待决策事项数**:有多少提案等待表决 → 点击查看提案清单 +3. **代表预警数**:有多少履职卡标红 → 点击查看预警详情 + +所有深度操作在组织管理详情页完成,不涌入全景图。 + +## 场景:从机构异常到管理动作 + +**决策频率**:每次会议周期后。**决策者**:书记处/公司代表。 + +### 交互流程 + +1. 打开全景图,职能线组织管理模块显示"1个机构逾期"(红色标记) +2. 点击跳转至机构看板,技术委员会卡片标红——已两周未开会(章程频率为每周一次) +3. 点击技术委员会卡片,查看详情:上次会议记录、待处理提案列表、成员出席状态 +4. 书记处确认后,可触发"提醒主持人"通知,或直接在技术委员会看板中"发起议题" +5. 技术委员会回复议题后,机构卡片的红色标记降级为黄色(已响应但未解决) +6. 会议召开并形成提案后,机构卡片恢复正常状态 + +### 界面响应 + +- 全景图职能线始终显示最新的机构健康度 +- 机构看板按异常程度排序:逾期 > 即将到期 > 正常 +- 代表履职卡的绿色标记只在当日有效,过时自动熄灭 diff --git a/docs/prd/qtconsult.md b/docs/prd/qtconsult.md new file mode 100644 index 00000000..2309a5ef --- /dev/null +++ b/docs/prd/qtconsult.md @@ -0,0 +1,94 @@ +# 量潮咨询 PRD + +> 量潮科技旗下业务线之一。 + +## 设计目标 + +把咨询项目的"发现→策略"循环,压缩到一个页面里,用轻量的交互强制推动这个循环转动起来。 + +不做一个文档编辑器,也不做一个项目进度看板。做一个**双栏联动的决策辅助工具**: +- 左栏记录对客户的认知(信息看板) +- 右栏记录基于认知的策略(策略看板) +- 两条线之间通过自动提醒强制关联 + +## 三层设计 + +### 第一层:发现可溯源、可触发动作 + +信息看板的每一条发现都需要包含: + +- **类型**:风险 / 需关注 / 机会 / 中性 +- **状态**:待确认 / 已确认 / 驳回 +- **来源**:哪次会议、谁提出的 + +关键机制:**类型为"风险"或"需关注"的发现,提交后自动在策略看板中追加一条"策略待审视"记录**。不是发通知,而是在策略看板里直接生成一条修正记录,状态为"待审视"。顾问打开策略看板就会看到——"你有一条新发现需要考虑是否调整策略"。 + +### 第二层:策略作为活文档 + +策略看板不只是一个表单,它记录了策略的演变过程: + +- **当前策略版本**:战略诉求、切入策略、决策链路,始终展示最新版 +- **策略修正历史**:按时间倒序排列,每条修正记录包含: + - 触发的发现(来自信息看板的哪条发现) + - 修正了什么(具体哪个模块发生了变化) + - 状态标记(已审视 / 待审视) + +策略修正确认后,"待审视"变为"已审视",修正记录保留作为回溯依据。 + +### 第三层:利益相关者管理嵌入日常 + +决策链路不是静态角色表。每个人展示: + +- **立场**:支持 / 中立 / 反对(可变更) +- **核心顾虑**:一句话概括(如"关注ROI"、"倾向大厂方案") +- **应对策略**:针对该人的沟通重点 + +当顾问调整策略时,决策链内容始终显示在策略看板中,不需要翻到其他页面去查看"谁是什么态度"。 + +## 场景:发现驱动的策略迭代 + +**决策频率**:每次客户接触后。**决策者**:项目顾问。 + +### 交互流程 + +1. 顾问在信息看板添加一条新发现(例如"财务王总从反对转为中立"),填写类型为"需关注" +2. 系统自动在策略看板的修正历史中追加一条记录:"新发现:财务王总立场变化 → 策略待审视",标记为"待审视" +3. 策略看板的决策链路中,财务王总的立场从"反对"更新为"中立" +4. 顾问审视当前策略: + - 分期方案是否还需要作为重点准备?→ 可以调整优先级 + - CEO的态度是否需要重新评估?→ 财务软化后,CEO的决策阻力减小 +5. 确认调整后,修正记录标记为"已审视",保存本次调整的原因 + +### 界面响应 + +- 有待审视的策略修正时,策略看板标题旁显示红点或角标 +- 策略看板在没有待审视项时保持安静,不打扰顾问 + +## 双Workspace工作空间设计 + +量潮咨询同时服务于两个Workspace工作空间,共享同一套交互框架,但Workspace工作空间隔离决定了数据源和观察者立场。 + +| 维度 | 客户Workspace工作空间 | 内部Workspace工作空间 | +|------|----------|----------| +| 使用者 | 量潮科技顾问 | 创始人 / 量潮科技 | +| 数据源 | 客户提供的信息 | 量潮云(公司运营数据) | +| 观察立场 | 外部视角看客户 | 独立观察者看公司 | +| "发现"来源 | 调研会、沟通记录 | 量潮云提供的现状与偏差 | + +内部Workspace工作空间的"客户"是使用者自身。创始人打开内部项目时,看到的是量潮云提供的公司现状作为"发现",以外部咨询顾问的姿态审视并制定策略。量潮科技打开内部项目时同理——它把自己当成一个被咨询的对象来审视。 + +Workspace工作空间隔离在此的意义——不是数据安全,而是**认知隔离**:强制制造观察者与被观察者的结构边界,让使用者的"外部咨询师"身份无法被内部叙事同化。一旦内部Workspace工作空间与客户Workspace工作空间共享同一套数据视图,观察者视角就坍缩回内部视角,失去独立判断能力。 + +## 与非功能需求的关系 + +- **无需填进度**:顾问不需要更新"项目完成了百分之几",进度是策略执行的副产物 +- **无需切页面**:信息看板和策略看板在同一页面左右并排,顾问不需要在不同页面之间跳转 +- **无需记住上下文**:看了发现再看策略,所有相关信息都在当前页面内 + +## 与全景图的关系 + +量潮咨询在全景图中只展示两样东西: +- 进行中项目的当前阻塞点 +- 待承接客户的匹配度评估 + +所有深度工作在咨询详情页完成,不涌入全景图。 diff --git a/docs/user-guide/asset.md b/docs/user-guide/asset.md new file mode 100644 index 00000000..238a4ecd --- /dev/null +++ b/docs/user-guide/asset.md @@ -0,0 +1,95 @@ +# 数字资产职能 + +通过 `qtadmin asset` 命令管理数字资产。 + +无参数时显示简要帮助;`qtadmin asset --help` 或 `qtadmin asset -h` 列出所有子命令及用法。 + +## 安装 + +```bash +pip install qtadmin-cli +# 或从源码安装 +pip install -e src/cli +``` + +--- + +## 命令 + +### `qtadmin asset backup` (stable) — 日志归档 + +将 `docs/journal/` 下的过期日志移到 `docs/archive/journal/`,自动提交并推送子模块。建议每周运行一次。 + +```bash +qtadmin asset backup # 归档 3 天前的日志(默认) +qtadmin asset backup --days 7 # 归档 7 天前的日志 +qtadmin asset backup --dry-run # 预览模式,不实际移动 +qtadmin asset backup --yes # 跳过确认直接执行 +qtadmin asset backup -y # 同上,短格式 +``` + +默认会询问确认;使用 `--yes` / `-y` 跳过交互,`--dry-run` 预览变更。 + +执行输出: + +``` +$ qtadmin asset backup -y +项目根目录:/home/user/project +扫描到 38 个日志文件 +开始归档... +已移动:docs/journal/default/2026-05-28.md -> docs/archive/journal/default/2026-05-28.md +提交子模块变更... +已推送:docs/journal +归档完成! +``` + +常见错误: +- 子模块存在未提交变更时,`backup` 会先尝试提交,失败则提示用户手动处理 +- 网络断开导致 push 失败时,命令输出推送错误信息,本地 commit 仍然保留 + +### `qtadmin asset audit` (stable) — 资产审计 + +审计 Git 仓库是否符合标准资产体系规范。建议发布前运行。 + +```bash +qtadmin asset audit # 审计当前目录 +qtadmin asset audit /path/to/repo # 审计指定仓库 +qtadmin asset audit --verbose # 显示所有通过项目 +``` + +审计通过时退出码为 0,未通过时退出码为 1。 + +审计项: +- 必需文件:README.md、CONTRIBUTING.md、AGENTS.md、CHANGELOG.md、.gitignore +- 上述文件的内容规范 +- 子模块状态(未推送的提交会被标记) +- 提交信息是否符合 Conventional Commits +- CHANGELOG 与 pyproject.toml 版本一致性 + +执行输出: + +``` +$ qtadmin asset audit +✅ 所有审计项通过 + +$ qtadmin asset audit --verbose +✅ 必需文件:README.md — 通过 +✅ 必需文件:CONTRIBUTING.md — 通过 +… +✅ 提交规范符合度 — 3/3 符合 (100%) +✅ 版本发布规范一致性 — 通过 +``` + +--- + +## 限制 + +- `asset refresh` 已移除(功能已迁移至其他工具) +- `asset apply` 规划中,尚未实现 + +## 说明 + +- 两个命令均经过单元测试和集成测试覆盖,可在 v0.0.1 生产使用 +- 更多用法参见 `qtadmin asset backup --help`、`qtadmin asset audit --help` +- 详细文档见 `src/cli/docs/user/asset_backup.md` +- 在线文档:[https://github.com/quanttide/quanttide-tech](https://github.com/quanttide/quanttide-tech) diff --git a/docs/user-guide/business.md b/docs/user-guide/business.md new file mode 100644 index 00000000..dbff1966 --- /dev/null +++ b/docs/user-guide/business.md @@ -0,0 +1 @@ +# 商务拓展职能 diff --git a/docs/user-guide/finance.md b/docs/user-guide/finance.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/user-guide/human.md b/docs/user-guide/human.md new file mode 100644 index 00000000..0ae477e9 --- /dev/null +++ b/docs/user-guide/human.md @@ -0,0 +1,475 @@ +# 人力资源模块 — 零基础使用教程 + +本教程教你从零开始搭建一套招聘管道管理系统:接收简历邮件 → AI 自动分类 → 人工确认 → 进入招聘看板追踪。 + +## 目录 + +1. [系统概览](#1-系统概览) +2. [环境准备](#2-环境准备) +3. [启动 Provider(数据后端)](#3-启动-provider数据后端) +4. [安装 CLI 命令行工具](#4-安装-cli-命令行工具) +5. [连接飞书邮箱](#5-连接飞书邮箱) +6. [配置 AI 智能分类](#6-配置-ai-智能分类) +7. [完整使用教程](#7-完整使用教程) +8. [打包项目](#8-打包项目) +9. [常见问题](#9-常见问题) + +--- + +## 1. 系统概览 + +整个系统由 3 个部分组成: + +``` +飞书邮箱 ──→ CLI(拉取邮件+分类)──→ Provider API(数据+AI)──→ 看板页面 + ↑ │ + └───── 定时轮询发件箱 ──────┘ +``` + +| 组件 | 作用 | 端口 | +|------|------|------| +| **Provider** | 数据后端,存数据库、提供 API、AI 分类 | 8080 | +| **CLI** | 命令行工具,拉取飞书邮件、推送分类结果 | — | +| **看板页面** | Web 界面,管理候选人管道 | 8000 | + +--- + +## 2. 环境准备 + +### 2.1 安装 Python 3.10+ + +```bash +python3 --version +``` + +如果低于 3.10,请到 [python.org](https://python.org) 下载安装。 + +### 2.2 安装 Node.js(飞书集成需要) + +```bash +node --version +npm --version +``` + +如果未安装,到 [nodejs.org](https://nodejs.org) 下载 LTS 版本。 + +### 2.3 获取项目代码 + +```bash +# 克隆项目(如果还没有) +git clone <项目仓库地址> qtadmin +cd qtadmin +``` + +> 如果已有项目代码,直接进入项目目录即可。 + +### 2.4 目录结构 + +``` +qtadmin/ +├── src/provider/ # Provider API(数据后端) +├── src/cli/ # CLI 命令行工具 +├── examples/human/ # Demo 演示(带 Web 页面) +├── docs/user-guide/ # 本文档 +└── manifests/ # systemd 服务配置(生产用) +``` + +--- + +## 3. 启动 Provider(数据后端) + +Provider 是所有数据的总源头,必须第一个启动。 + +### 3.1 创建虚拟环境并安装 + +```bash +cd src/provider + +# 创建虚拟环境(仅首次) +python3 -m venv .venv + +# 安装依赖 +.venv/bin/pip install -e . +``` + +### 3.2 启动服务 + +```bash +.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8080 +``` + +看到以下输出即成功: + +``` +INFO: Started server process [12345] +INFO: Uvicorn running on http://0.0.0.0:8080 +``` + +首次启动会自动创建 `hr.db` 数据库文件并写入 40 条示例数据。 + +### 3.3 验证 + +```bash +# 新开一个终端,检查服务是否正常 +curl http://127.0.0.1:8080/health +# 返回 {"status":"ok"} 即正常 + +# 查看管道数据 +curl http://127.0.0.1:8080/pipeline +``` + +> Provider 启动后不要关闭终端,后续所有操作都在新终端中进行。 + +--- + +## 4. 安装 CLI 命令行工具 + +### 4.1 创建虚拟环境并安装 + +```bash +# 新开一个终端 +cd qtadmin/src/cli + +# 创建虚拟环境 +python3 -m venv .venv + +# 安装 +.venv/bin/pip install -e . +``` + +### 4.2 验证安装 + +```bash +.venv/bin/qtadmin --help +``` + +看到帮助信息即安装成功。 + +### 4.3 配置 Provider 地址 + +告诉 CLI 你的 Provider 运行在哪里: + +```bash +.venv/bin/qtadmin human config set-provider http://127.0.0.1:8080 + +# 查看配置 +.venv/bin/qtadmin human config show +``` + +输出应类似: + +``` +当前配置: + provider_url: http://127.0.0.1:8080 + lark_path: lark-cli +``` + +--- + +## 5. 连接飞书邮箱 + +连接飞书邮箱后,系统可以自动拉取招聘邮箱中的简历邮件。 + +### 5.1 安装 lark-cli + +```bash +# 全局安装飞书命令行工具 +npm install -g @larksuite/cli + +# 验证安装 +lark-cli --version +``` + +### 5.2 登录飞书 + +```bash +lark login +``` + +浏览器会自动打开飞书登录页面,扫码登录即可。 + +> 如果浏览器没有自动打开,复制终端显示的链接手动打开。 + +### 5.3 验证登录 + +```bash +# 查看邮箱列表,确认你能访问目标邮箱 +lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"你的邮箱@example.com"}' +``` + +### 5.4 配置 CLI 使用 lark-cli + +```bash +.venv/bin/qtadmin human config set-lark-path $(which lark-cli) +``` + +### 5.5 测试邮件拉取 + +```bash +# 列出收件箱最近 5 封邮件 +.venv/bin/qtadmin human list -n 5 +``` + +如果能列出邮件,说明飞书集成成功。 + +--- + +## 6. 配置 AI 智能分类 + +AI 分类器可以自动判断一封邮件是简历、笔试、面试还是 Offer,并提取候选人姓名。 + +### 6.1 准备工作 + +你需要一个 **OpenAI 兼容的 API 密钥**。支持: +- OpenAI:`sk-...`(需科学上网) +- 国内替代:DeepSeek、智谱、通义千问等(无需科学上网) + +### 6.2 通过 API 配置(推荐) + +```bash +# 启用 AI 并设置密钥(以 DeepSeek 为例) +curl -X PATCH http://127.0.0.1:8080/ai/config \ + -H "Content-Type: application/json" \ + -d '{ + "enabled": true, + "provider": "openai", + "base_url": "https://api.deepseek.com/v1", + "api_key": "sk-你的密钥", + "model": "deepseek-chat" + }' +``` + +国内常用 AI 服务: + +| 服务商 | base_url | model | +|--------|----------|-------| +| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` | +| 智谱 | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` | +| 通义千问 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-turbo` | +| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` | + +### 6.3 测试 AI 配置 + +```bash +curl -X POST http://127.0.0.1:8080/ai/test +``` + +返回 `{"status":"ok","message":"连接成功"}` 即配置正确。 + +### 6.4 在 Web 页面配置 + +打开 http://127.0.0.1:8000/ → 点击右侧 **⚙️ AI 配置** → 填入: +1. 勾选"启用 AI 分类" +2. 填入 API 地址(如 `https://api.deepseek.com/v1`) +3. 填入 API 密钥 +4. 填入模型名(如 `deepseek-chat`) +5. 点击保存 + +--- + +## 7. 完整使用教程 + +### 7.1 启动看板页面 + +```bash +# 新开一个终端 +cd qtadmin + +# 启动 Demo(使用 Provider 的虚拟环境) +QTADMIN_MAILBOX=你的邮箱@example.com \ + PYTHONPATH=src/provider \ + src/provider/.venv/bin/python examples/human/demo.py +``` + +> 设置 `QTADMIN_MAILBOX` 后,系统会自动轮询该邮箱。 + +打开浏览器访问 **http://127.0.0.1:8000/**。 + +### 7.2 每日工作流程 + +#### 步骤 1:拉取邮件并分类 + +```bash +# 查看收件箱有哪些邮件 +.venv/bin/qtadmin human list -n 20 + +# 预览某封邮件的分类结果 +.venv/bin/qtadmin human classify <邮件ID> +``` + +#### 步骤 2:推送到确认队列 + +```bash +# 推送所有未处理邮件到待确认队列 +.venv/bin/qtadmin human ingest +``` + +#### 步骤 3:在 Web 页面确认 + +打开 http://127.0.0.1:8000/ → 点击 **待确认队列**: +- 查看邮件内容、附件、AI 分类结果 +- 点击 **确认入队** → 候选人自动进入管道 +- 点击 **忽略** → 丢弃该邮件 + +#### 步骤 4:管理招聘管道 + +管道看板将候选人按 8 个阶段排列: + +``` +新进 → 已联系 → 已发卷 → 已收卷 → 评卷中 → 面试 → 发Offer → 关闭 +``` + +操作: +- **拖拽** 候选人卡片到下一阶段 +- **点击候选人** 查看详情、时间线、消息记录 +- **查看附件** — PDF 直接预览,Word 文档自动转 PDF 在线预览 + +#### 步骤 5:查看队列状态 + +```bash +# 查看待确认队列统计 +.venv/bin/qtadmin human status +``` + +### 7.3 自动轮询模式 + +系统支持两种自动模式: + +**邮件拉取轮询**(在 Provider 或 Demo 中): +- 设置 `QTADMIN_MAILBOX` 环境变量后自动启用 +- 每 5 分钟检查一次新邮件 +- 新邮件自动推送至 `/ingest` 端点 + +**发件箱轮询**(邮件发送守护进程): +```bash +# 启动邮件发送循环(每 30 秒检查一次) +.venv/bin/qtadmin human send-loop -i 30 +``` + +--- + +## 8. 打包项目 + +### 8.1 打包 CLI 工具 + +```bash +cd src/cli + +# 构建可分发的 wheel 包 +.venv/bin/pip install build +.venv/bin/python -m build + +# 生成的包在 dist/ 目录 +ls dist/ +# qtadmin_cli-0.0.1-py3-none-any.whl + +# 安装到其他环境 +pip install dist/qtadmin_cli-0.0.1-py3-none-any.whl +``` + +### 8.2 打包 Provider + +```bash +cd src/provider + +# 安装 build 工具 +.venv/bin/pip install build +.venv/bin/python -m build + +# 查看生成的包 +ls dist/ +# qtadmin_provider-0.1.0-py3-none-any.whl +``` + +### 8.3 打包完整项目(含依赖) + +创建一个 requirements.txt 包含所有依赖: + +```bash +cd src/provider +.venv/bin/pip freeze > requirements.txt + +# 这样部署时只需: +# python3 -m venv .venv +# .venv/bin/pip install -r requirements.txt +# .venv/bin/pip install dist/qtadmin_provider-0.1.0-py3-none-any.whl +``` + +### 8.4 配置开机自启(生产环境) + +```bash +# 复制服务配置 +cp manifests/qtadmin-provider.service ~/.config/systemd/user/ +cp manifests/qtadmin-mail-sender.service ~/.config/systemd/user/ + +# 重新加载 systemd +systemctl --user daemon-reload + +# 启动服务 +systemctl --user start qtadmin-provider +systemctl --user start qtadmin-mail-sender + +# 设置开机自启 +systemctl --user enable qtadmin-provider +systemctl --user enable qtadmin-mail-sender +``` + +--- + +## 9. 常见问题 + +### Q:端口被占用怎么办? + +```bash +# 查看谁在用端口 +ss -tlnp | grep 8080 + +# 杀掉进程 +kill -9 +``` + +### Q:lark-cli 找不到命令? + +确保 Node.js 全局 bin 目录在 PATH 中: + +```bash +# 查看 npm 全局安装路径 +npm config get prefix +# 例如输出 /home/你的用户名/.npm-global + +# 将 bin 目录加入 PATH +export PATH=$PATH:/home/你的用户名/.npm-global/bin + +# 永久生效(加到 ~/.bashrc) +echo 'export PATH=$PATH:/home/你的用户名/.npm-global/bin' >> ~/.bashrc +``` + +### Q:数据库被锁定? + +```bash +# 删除数据库后重启 Provider(数据会重新初始化) +rm src/provider/hr.db +``` + +### Q:AI 分类没生效? + +1. 确认 Provider 正在运行 +2. 调用 `GET /ai/config` 检查 `enabled` 是否为 `true` +3. 调用 `POST /ai/test` 测试连接 +4. 检查 API 密钥是否正确 + +### Q:如何重置所有数据? + +```bash +# 停止 Provider +# 删除数据库 +rm src/provider/hr.db +# 重启 Provider(自动重新生成种子数据) +``` + +### Q:飞书邮件收不到? + +1. 确认 `lark login` 已成功登录 +2. 确认邮箱地址正确:`lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"你的邮箱"}'` +3. 确认收件箱中有邮件:`lark-cli mail +triage --format json --max 5 --mailbox 你的邮箱` +4. 检查 Provider 是否设置了 `QTADMIN_MAILBOX` 环境变量 diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md new file mode 100644 index 00000000..d3b91703 --- /dev/null +++ b/docs/user-guide/index.md @@ -0,0 +1,6 @@ +# 用户指南 + +量潮管理后台分为业务和职能两类,以创始人视角统一管理公司各项事务。 + +业务:量潮数据、量潮课堂、量潮咨询、量潮云等。 +职能:人力资源、财务管理、商务拓展、数字资产等。 diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000..dc4af9de --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.pytest_cache/ \ No newline at end of file diff --git a/examples/default/.gitkeep b/examples/default/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/default/journal_report.md b/examples/default/journal_report.md new file mode 100644 index 00000000..bf58fd2d --- /dev/null +++ b/examples/default/journal_report.md @@ -0,0 +1,290 @@ +# 日志分析报告 +生成时间: 2026-05-06 02:55 +日志天数: 46 天 (2026-03-11 ~ 2026-05-05) +去重词数: 4413 +--- +## 总词频 Top 30 + +| 词 | 频次 | 出现天数 | +|---|---|---| +| 工作 | 354 | 39 | +| 感觉 | 309 | 42 | +| AI | 291 | 39 | +| 问题 | 264 | 41 | +| 日志 | 228 | 25 | +| 思考 | 214 | 29 | +| 发现 | 213 | 35 | +| 团队 | 195 | 35 | +| 想法 | 191 | 38 | +| 就是说 | 175 | 24 | +| 事情 | 171 | 38 | +| 系统 | 170 | 34 | +| 认知 | 158 | 29 | +| 过程 | 156 | 34 | +| 东西 | 155 | 29 | +| 里面 | 154 | 24 | +| 平台 | 147 | 31 | +| 标准 | 139 | 31 | +| 重要 | 124 | 33 | +| 资产 | 116 | 18 | +| 整个 | 110 | 28 | +| 比如说 | 109 | 26 | +| 流程 | 107 | 28 | +| 模型 | 106 | 21 | +| 直接 | 103 | 35 | +| 很多 | 97 | 28 | +| 管理 | 96 | 27 | +| 主要 | 95 | 31 | +| 考虑 | 94 | 28 | +| 方法 | 94 | 32 | + +## 每日关键词 + +- **2026-03-11**: 工作(0.15), 日志(0.13), 可以(0.13), 想法(0.08), 整理(0.07) +- **2026-03-12**: 文档(0.23), ##(0.20), AI(0.19), 工作(0.08), 代码(0.07) +- **2026-03-13**: 日志(0.20), AI(0.16), ##(0.14), 工作(0.11), 知识库(0.11) +- **2026-03-14**: 日志(0.20), ##(0.20), 工作(0.13), 模型(0.12), 知识(0.11) +- **2026-03-15**: 日志(0.39), AI(0.32), ##(0.28), 核心(0.13), 模型(0.10) +- **2026-03-16**: 日志(0.15), AI(0.09), 这个(0.08), 格式(0.08), 其实(0.07) +- **2026-03-17**: 归档(0.24), 日志(0.12), 可以(0.10), 觉得(0.10), 这个(0.09) +- **2026-03-18**: 日志(0.12), 可以(0.09), 工作(0.09), AI(0.07), 模型(0.07) +- **2026-03-19**: 业务(0.14), 工作手册(0.12), 我要(0.11), 就是(0.10), 团队(0.10) +- **2026-03-20**: 然后(0.12), 这个(0.12), AI(0.10), 就是(0.09), 大脑(0.09) +- **2026-03-21**: AI(0.12), 可以(0.12), 日志(0.11), 觉得(0.07), Kimi(0.06) +- **2026-03-22**: AI(0.15), 思考(0.07), blueprint(0.07), 可以(0.07), roadmap(0.06) +- **2026-03-23**: AI(0.18), 可以(0.10), 系统(0.08), 需要(0.07), 感觉(0.07) +- **2026-03-24**: DeepSeek(0.21), 写作(0.16), AI(0.15), 或许(0.14), 模型(0.14) +- **2026-03-25**: 可以(0.18), 不同(0.12), 比如(0.11), 自己(0.10), GUI(0.09) +- **2026-03-26**: 展示(0.27), 平台(0.17), 恰恰(0.14), 团队(0.13), 数字(0.12) +- **2026-03-27**: 教程(0.14), 经营(0.13), 分解(0.13), 或许(0.09), 有个(0.08) +- **2026-03-28**: 审计(0.10), 可以(0.10), AI(0.10), 秘书处(0.09), 自己(0.08) +- **2026-03-29**: 理论(0.67), 盲人摸象(0.32), 现实(0.31), 严厉批评(0.27), 技术规范(0.27) +- **2026-03-30**: 日志(0.18), 九宫格(0.16), 工作(0.11), 格式(0.08), 原始(0.07) +- **2026-03-31**: 然后(0.11), 觉得(0.09), 就是(0.09), 平台(0.09), AI(0.08) +- **2026-04-01**: 就是(0.15), 感觉(0.11), 然后(0.10), 这个(0.10), Skill(0.09) +- **2026-04-02**: 罚钱(0.17), 我们(0.12), 就是(0.10), 觉得(0.08), 团队(0.07) +- **2026-04-03**: 思考(0.26), 团队(0.26), 感觉(0.16), 自己(0.10), 麻辣烫(0.10) +- **2026-04-04**: 大脑(0.18), git(0.14), 或许(0.11), 第二(0.10), 个人(0.09) +- **2026-04-06**: 然后(0.20), 愉悦感(0.15), 提取(0.15), 碎片(0.14), 想法(0.14) +- **2026-04-07**: 然后(0.16), 这个(0.15), 编程(0.14), 就是(0.13), 链条(0.12) +- **2026-04-09**: 流程(0.29), AI(0.15), 日志(0.13), 可以(0.13), 想法(0.13) +- **2026-04-18**: 然后(0.14), 就是(0.13), 这个(0.11), 一个(0.10), 日志(0.08) +- **2026-04-19**: 案例(0.15), AI(0.15), 然后(0.14), 需要(0.10), 就是(0.09) +- **2026-04-20**: 范畴(0.29), AI(0.12), 可以(0.10), 推理(0.10), 就是(0.10) +- **2026-04-21**: 然后(0.20), AI(0.14), 这个(0.13), 就是(0.08), 案例(0.08) +- **2026-04-22**: 然后(0.12), 代码(0.11), 这个(0.10), 就是(0.09), 资产(0.09) +- **2026-04-23**: 这个(0.14), 然后(0.12), 建模(0.12), 就是(0.11), 日志(0.10) +- **2026-04-24**: 思考(0.18), AI(0.17), 就是(0.13), 然后(0.12), 这个(0.10) +- **2026-04-25**: 然后(0.12), 就是(0.10), 就是说(0.09), 认知(0.08), 这个(0.08) +- **2026-04-26**: 认知(0.16), 然后(0.15), 这个(0.15), situation(0.12), 就是(0.10) +- **2026-04-27**: 宣传册(0.24), 简介(0.14), 叙事(0.14), 视角(0.11), 客户(0.10) +- **2026-04-28**: 然后(0.16), 我们(0.12), 客户(0.09), 团队(0.09), 一个(0.09) +- **2026-04-29**: 然后(0.21), 就是(0.15), 一个(0.10), 游戏(0.09), 觉得(0.09) +- **2026-04-30**: 平台(0.13), 团队(0.11), 用户(0.08), 或许(0.08), 其实(0.08) +- **2026-05-01**: 然后(0.21), 游戏(0.16), AI(0.12), 感觉(0.12), 这个(0.11) +- **2026-05-02**: 建模(0.19), 游戏(0.16), 觉得(0.13), 就是(0.13), HTML(0.11) +- **2026-05-03**: 然后(0.17), 就是(0.13), 觉得(0.13), 东西(0.10), 一个(0.10) +- **2026-05-04**: 这个(0.12), 然后(0.12), 就是(0.10), 可以(0.09), 观察(0.09) +- **2026-05-05**: 然后(0.20), 这个(0.13), 后台(0.08), 就是(0.07), 一个(0.07) + +## 概念趋势 + +- **工作**: 共 354 次, 38 天, 首现 2026-03-11, 最近 2026-05-05 +- **感觉**: 共 309 次, 42 天, 首现 2026-03-11, 最近 2026-05-05 +- **AI**: 共 291 次, 38 天, 首现 2026-03-11, 最近 2026-05-05 +- **问题**: 共 264 次, 41 天, 首现 2026-03-11, 最近 2026-05-05 +- **日志**: 共 228 次, 25 天, 首现 2026-03-11, 最近 2026-05-05 +- **思考**: 共 214 次, 29 天, 首现 2026-03-11, 最近 2026-05-05 +- **发现**: 共 213 次, 35 天, 首现 2026-03-11, 最近 2026-05-05 +- **团队**: 共 195 次, 35 天, 首现 2026-03-11, 最近 2026-05-04 +- **想法**: 共 191 次, 38 天, 首现 2026-03-11, 最近 2026-05-05 +- **就是说**: 共 175 次, 22 天, 首现 2026-03-13, 最近 2026-05-05 + +## 突发词(按天) + +- **2026-03-12**: 文档(19/2.0), 标准(8/4.0), 思路(5/1.0), 各种(4/1.0), 状态(4/2.0), 安全(4/1.0), GitHub(3/1.0) +- **2026-03-13**: 模式(14/1.0), 知识库(13/2.5), 默认(11/1.5), 系统(10/1.5), 任务(9/1.0), 思考(9/4.0), 状态(6/3.0), 不断(6/1.5), 格式(6/1.0), 想要(5/1.5), 数据(5/1.5), 处理(5/1.5) +- **2026-03-14**: 模型(12/4.3), 执行(7/1.0), 信息(6/1.7), 事情(5/2.3), 路线(4/1.3), 环节(4/0.7), 程序(4/1.0), 功能性(4/0.3), 重要(4/2.0), 记忆(4/1.7), 领域(4/1.7), 里面(4/1.7), 单元(4/0.3), 对齐(4/0.7), 设计(3/0.7) +- **2026-03-15**: 核心(13/1.2), 记录(8/3.2), 人工(8/0.5), 处理(6/3.0), 叙事(6/1.2), 工程(5/1.8), 时间(5/1.2), 开源(4/1.0), 内容(4/0.8), 周报(4/0.5), 范式(3/1.2), 日报(3/0.8), 事件(3/1.0), 社区(3/0.8), 机制(3/0.5) +- **2026-03-16**: 就是说(7/0.2), 原始(7/2.2), 格式(7/2.2), 事情(6/2.4), 做法(4/0.2), 安全(4/1.0), 归档(4/0.6), 治理(4/0.6), 清洗(3/0.8), 稍微(3/0.6), 之中(3/0.4), 似乎(3/0.2), 文本(3/1.0) +- **2026-03-17**: 归档(32/1.2), 系统(17/4.2), 或许(15/0.5), 考虑(10/3.5), 日报(10/1.0), 这是(8/1.2), 空间(8/0.3), 结构(8/1.0), 不断(8/2.0), 发现(8/2.5), 创造(7/0.3), 一定(6/0.7), 核心(6/3.0), 真正(6/1.0), 事情(6/3.0), 生成(6/3.0), 就是说(6/1.3), 手机(6/1.0), 以后(5/1.8), 程序(5/1.7), 价值(5/1.3) +- **2026-03-18**: 发现(8/3.3), 或许(7/2.6), 模式(6/2.4), 观察(6/0.9), 慢慢(4/0.4), 创造(4/1.3), 活动(4/0.4), 有意思(3/0.1), 尝试(3/1.0), 而是(3/0.7), 智能(3/1.1) +- **2026-03-19**: 业务(6/0.4), 慢慢(3/0.9), 客户(3/0.4), 公司(3/1.0) +- **2026-03-20**: 数据(15/1.9), 规则(12/0.9), 分类(10/1.1), 认知(10/0.7), 就是说(9/2.0), 程序(9/2.0), 一定(8/1.3), 团队(8/2.4), 状态(7/2.1), 不断(7/3.0), 本身(7/0.3), 复杂(7/0.6), 标准(6/2.7), 数字(6/0.1), 并且(6/1.1), 重要(6/1.9), 存在(6/0.7), 整个(6/2.1), 对齐(6/1.0), 结果(5/0.3), 设计(5/1.0), 比如说(5/0.9), 模糊(5/0.6) +- **2026-03-21**: 系统(12/5.9), 分类(9/2.0), 事情(8/3.3), 想法(8/2.3), 确实(6/0.4), 问题(6/3.0), 各种(6/0.9), 好像(5/0.6), 不过(5/1.3), 容易(5/1.3), Kimi(5/0.1), 主要(5/1.0), 处理(4/2.0), 每天(4/0.1), DeepSeek(4/0.4), 默认(4/2.0), 逐渐(4/1.6), 未来(4/1.7), 方法(4/1.1), 需求(4/1.1), 说明(3/0.3) +- **2026-03-22**: 产品(11/0.4), 思考(10/3.4), 或许(9/3.7), 问题(9/3.4), 实践(8/1.1), 需求(7/1.6), 工具(6/1.6), 这是(5/1.9), 执行(5/0.3), 适合(5/1.1), roadmap(5/0.3), 写作(4/0.4), 直觉(4/1.7), 慢慢(4/1.4), 各种(4/1.4), 难点(4/0.4), 清楚(4/1.6), 习惯(4/1.1), 迭代(4/1.1), 定义(4/0.6), 意义(4/1.0), 研发(4/0.1) +- **2026-03-23**: 产品(6/2.0), 反馈(5/0.7), 未来(5/2.1), 大脑(5/2.4), 整个(5/2.1), 方法(5/1.7), 想到(4/0.6), 生长(4/0.1), Kimi(4/1.0), 写作(4/1.0), ##(3/0.3), 不能(3/1.1), 大量(3/0.4), 完全(3/1.3), 元认知(3/0.6), 积累(3/0.6) +- **2026-03-24**: 或许(14/6.1), 写作(13/1.6), 模型(13/2.7), DeepSeek(11/1.6), 验证(7/1.0), 不过(6/2.3), 工程(6/0.6), 编程(4/0.4), 目前(4/1.3), 叙事(4/1.0), 理解(4/0.4), 模块(4/0.6), 人类(3/0.3), 文字(3/0.1), 思路(3/1.4), 利用(3/0.1) +- **2026-03-25**: 比如(5/0.7), 每天(3/0.6) +- **2026-03-26**: 平台(3/1.0) +- **2026-03-27**: 分解(5/0.1), 教程(4/0.6), 公司(4/0.7), 组织(3/0.6), 虽然(3/0.9) +- **2026-03-28**: 审计(7/0.4), 数据(5/0.9), 公司(4/1.1), 代码(4/0.1), 类似(4/0.4), 人类(4/0.6), 自由(3/0.1), 状态(3/1.4), 建立(3/1.1), 整合(3/0.6), 提供(3/0.6), 经验(3/0.1), 平台(3/1.1), 研发(3/0.7), 信息(3/1.0), 擅长(3/0.3) +- **2026-03-30**: 工作(14/2.0), 日志(9/0.4), 原始(6/0.1), 九宫格(6/0.1), 档案(5/0.3), 格式(5/0.4), 信息(5/0.9), 逻辑(4/0.1), 公开(4/0.3), 开发(4/0.3), 财务(4/0.1), 习惯(3/0.6), 东西(3/0.3), 似乎(3/0.1), 输入(3/0.1), journal(3/0.1), 标准(3/0.4), 直接(3/0.9), 确实(3/0.3), 资料(3/0.4), 代码(3/0.7) +- **2026-03-31**: 平台(16/0.9), 工作(10/3.4), 问题(9/2.1), 感觉(8/2.6), AI(8/2.4), 建立(7/0.9), 事情(7/1.0), 主要(6/0.9), 重要(6/0.4), 思考(6/2.1), 很多(6/0.9), 研发(5/0.4), 发现(5/0.7), 创造(5/0.6), 阅读(5/0.3), 一定(5/0.3), 具体(5/0.9), 切换(4/0.1), 思路(4/0.6), 学习(4/0.4), 管理(4/0.6), 尝试(4/0.1), 想法(4/1.6), 个人(3/0.1), 以后(3/0.4), 舒适(3/0.1) +- **2026-04-01**: 感觉(24/3.4), 工作(18/4.6), 资产(14/0.7), 标准(13/0.6), 里面(11/0.3), 数字(10/0.7), 框架(8/0.1), 逐渐(8/0.7), 简介(8/0.3), 定义(7/0.9), 未来(7/0.3), AI(6/2.4), 很多(6/1.3), 事情(6/2.0), 过程(6/0.7), 重要(6/1.3), 合适(6/1.0), profile(6/0.4), 九宫格(6/1.0), 想法(5/1.6), 治理(5/0.4), 考虑(5/0.7), 手册(5/0.6), 工程(5/0.3), 记忆(4/0.6), 发现(4/1.1) +- **2026-04-02**: 信息(5/2.0), 团队(4/1.3), 想要(3/0.3), 这件(3/0.4), 东西(3/1.4), 减少(3/0.3) +- **2026-04-03**: 团队(9/1.6), 思考(9/2.4), 公司(4/1.9), 帮助(4/0.3), 识别(3/0.3) +- **2026-04-04**: 大脑(4/0.6), 第二(3/0.6), 或许(3/1.1), 个人(3/0.9) +- **2026-04-06**: 想法(5/1.9), 知识(3/0.3), 卡片(3/0.1), 虽然(3/0.4), 包括(3/0.6), 关键(3/0.9) +- **2026-04-07**: 写作(3/0.1), 编程(3/1.0), 管理(3/0.9) +- **2026-04-09**: 流程(6/0.7), 整理(3/1.0) +- **2026-04-18**: 里面(20/2.0), 设计(15/0.3), 问题(14/1.6), 资产(12/2.1), 日志(11/0.7), 工作(10/3.6), 平台(8/1.7), 解决(7/0.3), 事情(7/2.4), 直接(7/0.9), 验证(7/0.6), 思考(6/1.6), 比如说(6/0.6), 主要(6/0.6), 保持(5/0.1), 代码(5/0.4), 想法(5/2.1), 整理(5/1.3), 数字(5/1.7), 那种(5/0.1), AI(5/1.9), 来讲(5/0.3), 放在(5/0.4), 重要(5/1.3), 文档(5/0.3) +- **2026-04-19**: 案例(11/0.3), AI(7/1.7), 工作(6/2.4), 资产(5/1.9), 标准(5/0.9), 视角(5/0.1), 市场(4/0.4), 才能(4/1.0), 产品(3/0.9), 合适(3/0.4), 清晰(3/0.3), 这是(3/0.6), 考虑(3/0.9), 样子(3/0.7), 整个(3/0.7), 思想(3/0.4), 简介(3/0.1) +- **2026-04-20**: 概念(7/0.1), 人类(6/0.6), 有点(5/0.6), 模式(5/0.1), 想要(4/0.7), 认知(4/0.1), 工程(4/0.3), 工具(4/0.7), 建模(3/0.4), 验证(3/1.4), 主要(3/1.1), 来讲(3/1.0), 复杂(3/0.4) +- **2026-04-21**: 发现(20/1.4), AI(16/3.4), 平台(16/1.6), 案例(14/1.9), 标准(14/1.1), 感觉(13/2.3), 工作(13/3.1), 里面(13/3.6), 东西(13/1.0), 事情(11/1.9), 问题(10/4.0), 资产(8/2.6), 知识(8/0.9), 方法(7/0.4), 放在(7/0.7), 这是(6/0.7), 主要(6/1.6), 系统(6/0.6), 刚才(6/0.1), 流程(6/1.7), 工程(6/0.9), 效果(5/0.6), 定义(5/0.6), 手册(5/0.4), 维护(5/0.1), 具体(5/0.3), 建模(5/0.9), 容器(5/0.1), 样子(5/1.1) +- **2026-04-22**: 资产(15/3.7), 代码(13/1.3), 管理(8/0.7), 风格(7/0.4), 比如说(6/1.6), 契约(6/1.0), 数字(6/1.3), 表达(5/0.6), 语境(5/0.1), 保留(5/0.1), 定义(4/1.1), devops(4/0.3), 版本(4/0.3), 理解(3/1.0), 控制(3/0.1), 之中(3/1.4), 逻辑(3/1.3), 想要(3/1.4), 文件(3/0.3), 一块(3/0.1), 约束(3/0.7) +- **2026-04-23**: 发现(47/4.9), 建模(47/1.7), 问题(46/5.6), 就是说(44/1.6), 日志(40/2.1), 资产(38/5.9), 过程(30/1.0), 标准(27/3.1), 感觉(24/4.1), 案例(24/3.9), 想法(22/2.6), 思考(20/1.0), 里面(20/5.7), 工作(20/4.7), 流程(19/2.4), 方法(17/1.7), 东西(17/2.9), 事情(17/3.7), 直接(17/2.4), 比如说(17/2.3), 团队(16/1.3), 重要(16/1.6), 刚才(14/1.1), 大家(13/0.6), 这是(13/1.7), 业务(13/0.9), 不断(13/0.9), AI(12/5.9), 数据(12/0.1), 考虑(12/1.6) +- **2026-04-24**: 思考(55/3.9), AI(31/7.3), 团队(26/3.6), 感觉(16/7.4), 过程(15/5.3), 认知(15/1.7), 东西(14/5.3), 人类(14/2.3), 原始(13/1.0), 整个(13/2.3), 事情(13/6.0), 重构(12/1.6), 大家(12/2.4), 状态(9/0.9), 叙事(9/1.3), 思维(9/1.7), 产生(8/1.1), 这件(7/1.1), 很多(7/1.7), 刚才(7/3.0), 模式(7/2.4) +- **2026-04-25**: 就是说(31/9.1), 团队(27/7.3), 认知(26/3.9), 标准(15/7.0), 透明(15/0.1), 制度(14/0.1), 公司(14/2.3), 比如说(13/5.1), 绩效(13/0.1), 整个(12/4.1), 系统(11/3.9), 主要(10/3.4), 大家(10/4.1), 强制(9/0.4), 变成(9/1.7), 很大(8/1.6) +- **2026-04-26**: 认知(56/7.4), 感觉(31/9.4), 过程(29/7.7), AI(25/11.1), 记录(23/1.4), 东西(21/8.9), 里面(20/7.4), 重要(18/4.6), 模型(16/1.4), 工程(15/3.6), entry(14/0.1), 状态(14/2.1), 概念(11/2.1), 系统(11/5.0), 找到(11/2.4), 定义(11/4.4), 记忆(11/0.4), 很多(10/3.4), 脑子(9/0.1) +- **2026-04-27**: 宣传册(5/0.4), 客户(4/0.7), 简介(4/0.6), 培训(3/0.1), 混乱(3/1.4) +- **2026-04-28**: 客户(10/1.3), 运营(7/0.4), 交流(5/0.3), 策略(5/0.4), 工具(5/1.4), 意识(5/2.0), 有没有(4/0.4), DeepSeek(4/1.3), 叫做(4/0.6), 核心(4/1.7), 经营(4/0.7), 公众(3/0.1), 内容(3/0.9), 一条(3/0.6), 有用(3/1.0), 困惑(3/0.7) +- **2026-04-29**: 那种(6/2.4), 驱动(5/1.3), 文章(5/1.1), 理想(5/1.0), 需求(4/1.3), DeepSeek(4/1.6), 整理(4/1.9), 公众(3/0.6), 演化(3/0.1), 文本(3/0.1), 输入(3/0.3) +- **2026-04-30**: 平台(7/2.7), 用户(4/0.9), 或许(4/0.4), 内容(3/1.3), 考核(3/0.1), 为了(3/1.1), 经验(3/1.1) +- **2026-05-01**: 游戏(22/1.3), 感觉(20/9.9), 复杂(7/1.1), 小说(6/0.6), 甚至(6/2.3), 机制(5/1.0), 样子(5/2.4), 故事(4/0.7), 产品(4/0.6), 课程(4/0.1), 主要(4/1.9), 设计(4/1.9), 一套(3/0.1), 互动(3/0.3) +- **2026-05-02**: 建模(4/1.3), 整合(3/0.3) +- **2026-05-03**: 东西(13/5.0), 风格(9/0.4), 平台(7/1.7), 写作(6/0.3), 找到(5/2.4), 大家(4/0.9), 机制(4/1.3), 休息(3/0.1), 产品(3/0.9), 不错(3/0.7), 那些(3/0.6), 简单(3/0.1), 地去(3/0.7), 很大(3/1.4), 这么(3/0.9), 看起来(3/0.1), 这是(3/1.1), 收集(3/0.4) +- **2026-05-04**: 观察(16/0.4), 产品(13/1.3), 平台(11/2.6), 发现(10/2.3), 更加(8/0.3), 问题(7/1.9), 变成(7/1.0), 写作(6/1.1), 智能(6/0.4), 思路(6/0.6), 方法(6/1.4), 比如说(6/2.7), AI(6/2.3), 比如(5/0.4), 核心(5/1.1), 认知(5/1.3), 管理(5/0.1), 系统(5/1.4), 工作(5/0.9), 人类(5/0.1), 本身(4/0.7), 这是(4/0.9), 重要(4/1.9) +- **2026-05-05**: 管理(27/0.9), 后台(18/0.7), 发现(17/3.6), 里面(15/1.6), 框架(14/0.3), 平台(14/4.1), 想法(13/2.3), 系统(12/2.1), 问题(12/2.7), 功能(11/0.4), 变化(11/0.4), 样子(11/1.9), 结构(10/1.1), 过程(10/1.9), 整个(9/0.9), 业务(8/0.4), 脑子(8/0.1), 思路(7/1.4), 工作(7/1.6), ---(7/0.4), 主线(7/0.1), 用来(7/0.4), 流程(6/1.1), 原型(6/0.6), 很多(6/1.4), 不断(6/0.9), 各个(6/0.3) + +## 关联网络:按情绪分类的共现关系 + +### 积极关系 + +| 词A | 词B | 出现次数 | +|---|---|---| +| 工作 | 日志 | 43 | +| 发现 | 工作 | 37 | +| AI | 发现 | 34 | +| 发现 | 重要 | 30 | +| 发现 | 过程 | 27 | +| 工作 | 知识 | 24 | +| 发现 | 日志 | 24 | +| 整个 | 系统 | 23 | +| 发现 | 问题 | 23 | +| 发现 | 就是说 | 23 | +### 消极关系 + +| 词A | 词B | 出现次数 | +|---|---|---| +| AI | 问题 | 32 | +| 工作 | 问题 | 29 | +| 就是说 | 问题 | 29 | +| 平台 | 问题 | 28 | +| 感觉 | 问题 | 27 | +| 事情 | 问题 | 25 | +| 里面 | 问题 | 25 | +| 解决 | 问题 | 24 | +| 发现 | 问题 | 23 | +| 工作 | 日志 | 22 | +### 中性关系 + +| 词A | 词B | 出现次数 | +|---|---|---| +| 工作 | 日志 | 121 | +| 工作 | 档案 | 57 | +| 数字 | 资产 | 57 | +| AI | 人类 | 56 | +| 工作 | 感觉 | 52 | +| 感觉 | 系统 | 52 | +| 工作 | 知识 | 51 | +| 大脑 | 第二 | 50 | +| 事情 | 这件 | 50 | +| AI | 感觉 | 49 | + +## 情绪倾向(按维度的逐日分布) + +各情绪维度总频次: + +- 启发/顿悟: 450 +- 建设/推进: 195 +- 困惑/混沌: 127 +- 成就/掌控: 126 +- 压力/焦虑: 80 +- 信任/期待: 78 +- 批判/不满: 7 +- 疲惫/倦怠: 7 + +情绪时间线 (逐日主导维度): + +- 2026-03-11: 启发/顿悟 (12) +- 2026-03-12: 启发/顿悟 (10) +- 2026-03-13: 启发/顿悟 (4) +- 2026-03-14: 建设/推进 (12) +- 2026-03-15: 建设/推进 (7) +- 2026-03-16: 困惑/混沌 (4) +- 2026-03-17: 启发/顿悟 (9) +- 2026-03-18: 启发/顿悟 (10) +- 2026-03-19: 成就/掌控 (2) +- 2026-03-20: 困惑/混沌 (12) +- 2026-03-21: 困惑/混沌 (4) +- 2026-03-22: 建设/推进 (14) +- 2026-03-23: 启发/顿悟 (5) +- 2026-03-24: 启发/顿悟 (7) +- 2026-03-25: 压力/焦虑 (2) +- 2026-03-26: 启发/顿悟 (3) +- 2026-03-27: 建设/推进 (2) +- 2026-03-28: 启发/顿悟 (4) +- 2026-03-30: 建设/推进 (3) +- 2026-03-31: 建设/推进 (11) +- 2026-04-01: 启发/顿悟 (8) +- 2026-04-02: 启发/顿悟 (4) +- 2026-04-03: 困惑/混沌 (1) +- 2026-04-04: 信任/期待 (1) +- 2026-04-06: 困惑/混沌 (1) +- 2026-04-07: 成就/掌控 (2) +- 2026-04-09: 启发/顿悟 (3) +- 2026-04-18: 建设/推进 (11) +- 2026-04-19: 启发/顿悟 (3) +- 2026-04-20: 启发/顿悟 (9) +- 2026-04-21: 启发/顿悟 (25) +- 2026-04-22: 启发/顿悟 (9) +- 2026-04-23: 启发/顿悟 (61) +- 2026-04-24: 启发/顿悟 (36) +- 2026-04-25: 启发/顿悟 (37) +- 2026-04-26: 启发/顿悟 (78) +- 2026-04-27: 建设/推进 (5) +- 2026-04-28: 困惑/混沌 (5) +- 2026-04-29: 启发/顿悟 (10) +- 2026-04-30: 启发/顿悟 (3) +- 2026-05-01: 启发/顿悟 (7) +- 2026-05-02: 启发/顿悟 (6) +- 2026-05-03: 信任/期待 (2) +- 2026-05-04: 启发/顿悟 (16) +- 2026-05-05: 启发/顿悟 (25) + +## 沉寂词(前7天高频但近期消失) + +- **##**: 前7天 64 次, 共 7 天 +- **知识**: 前7天 44 次, 共 20 天 +- **归档**: 前7天 39 次, 共 9 天 +- **标准**: 前7天 30 次, 共 31 天 +- **档案**: 前7天 26 次, 共 10 天 +- **生成**: 前7天 24 次, 共 18 天 +- **工具**: 前7天 21 次, 共 23 天 +- **格式**: 前7天 21 次, 共 15 天 + +## 长尾词拾遗 + +- 比较简单 (4天) +- 语义上 (4天) +- 省事 (4天) +- 不仅 (4天) +- 精密 (4天) +- 经历 (4天) +- coding (4天) +- 估计 (4天) +- 大型 (4天) +- 进一步 (4天) +- 差不多 (4天) +- 不少 (4天) +- 平时 (4天) +- 可变 (4天) +- 白天 (4天) + +## 写作行为元特征 + +- 日均字数: 2811 +- 日均词数: 633 +- 最产出日: 2026-04-23 (12748字) +- 最简短日: 2026-03-29 (121字) diff --git a/examples/default/llm_report.md b/examples/default/llm_report.md new file mode 100644 index 00000000..fe3f4139 --- /dev/null +++ b/examples/default/llm_report.md @@ -0,0 +1,44 @@ +### **日志分析报告:认知建构、系统探索与思维演进 (2026.03.11 - 2026.05.05)** + +这份报告基于您46天的日志记录,旨在超越简单的词频统计,勾勒出您在此期间的思维重心、认知模式、情绪流变及潜在的成长轨迹。整体来看,这段日志记录了一次从“方法的建立”到“系统的反思”再到“视角的外化”的连贯心智旅程。 + +#### **一、 核心叙事弧线:从“我的系统”到“我们的现实”** + +您的46天日志可以清晰地划分为三个相互关联的阶段,每个阶段有其主导的探索主题和情绪基调: + +1. **奠基期 (3月中旬 - 3月底):方法与工具的“归档”** + * **核心关注点**:日志格式、知识库、AI模型、工作手册。高频词“日志”、“归档”、“格式”、“模型”和突发词“文档”、“知识库”共同描绘了一个致力于将个人思考和工作流程**系统化、结构化**的开端。 + * **关键洞察**:您不是在随意记录,而是在有意识地**设计一套思维脚手架**。这个阶段充满了“建设/推进”的动力,是在为后续的深度探索打下方法论基础。 + +2. **爆发与深化期 (4月):认知内核的“建模”与“重构”** + * **核心关注点**:认知、资产、标准、系统、建模。此阶段概念密度极高,涌现了大量高阶词汇,如“认知”、“系统”、“标准”、“资产”、“建模”。 + * **关键转折**:**4月23日**是整个周期的思想与情绪最高峰(产出12748字,情绪“启发/顿悟”61次)。这一天,“发现”、“建模”、“问题”、“日志”、“资产”等词爆炸性增长,标志着认知的集中突破。您似乎在尝试将模糊的“思考”转化为可分析、可构建的“模型”。 + * **深度特征**:从24日到26日,“思考”、“认知”、“感觉”、“AI”、“人类”、“重构”等词持续高频,情绪高密度地集中在“启发/顿悟”。这表明您在触及“元认知”层面——不仅思考具体问题,更在反思“我是如何思考的”,甚至将AI(DeepSeek)作为新的认知工具和比较对象纳入这个过程。 + +3. **外化与应用期 (4月底 - 5月初):从思想到“产品”与“叙事”** + * **核心关注点**:游戏、平台、客户、后台、管理。进入5月,高频词和突发词转向“游戏”、“平台”、“客户”、“后台”、“管理”、“宣传册”,表明思考重心开始从内部认知架构转向外部**实践、呈现和产品化**。 + * **视角转换**:思考不再纯粹是内省的,而是开始面向“用户”和“市场”。“那这台机器的用户是谁?”这类隐含问题开始浮现。情绪基调中,“启发”依然存在,但“建设/推进”和“困惑/混沌”也相应增加,反映了将想法落地的实际挑战。 + +#### **二、 关键思维模式与变化分析** + +* **AI作为持续对话者与参照系**:“AI”几乎贯穿始终,与“思考”、“发现”、“问题”强关联。它不只是工具,更是一个**对等的思考伙伴和认知参照物**。4月份“认知”和“人类”的高频出现,暗示您在借由与AI的互动,反身性地定义和理解人类思维的独特性。 +* **从“动词”到“名词”的认知固化**:早期突发词多为“整理”、“归档”、“记录”等动作。后期,像“资产”、“标准”、“平台”这样的名词性概念变得更为核心。这反映了思维成果的**对象化和固化**——流动的想法正被凝结成可以持续观察和迭代的实体。 +* **“感觉”的恒常性与双重解读**:“感觉”一词在42天中出现309次,是仅次于“工作”的第二高频词,且与“问题”有强烈消极共现,与“系统”有中性共现。您非常注重自身的“感觉”体验,它既是发现问题的**探测器**(“感觉哪里不对”),也是工作流程和系统设计的**压力测试器**(“这个用起来感觉很奇怪”)。 +* **“就是说”作为思维连接词**:这个词的高频出现(175次)是一个非常有趣的口语化特征。它通常意味着您在不断地进行**自我解释、转译和精炼**,试图将一个模糊的想法,用更清晰、更底层的方式重新表述给自己听,是深度思维过程的显著标志。 + +#### **三、 情绪底色与周期性** + +1. **主导情绪“启发/顿悟”**:整份日志的压倒性情绪是“启发/顿悟”(450次),这并非情绪日记,而是一份**认知收获日记**。您的记录习惯本身就偏向于捕捉那些灵光一现的时刻,困难(“困惑/混沌”127次)则是这些启发的燃料。 +2. **情绪节奏与事件驱动**:情绪不是随机波动的。4月23-26日的情绪爆发显然是**概念突破的直接产物**。与之相对,4月末到5月初,随着思考转向外部实践,“困惑/混沌”开始增多,这正反映了从理论模型到具体应用的典型挑战。 +3. **一个值得关注的模式**:您的“压力/焦虑”(80次)虽然总量不大,但与“时间”、“任务”等词的出现可能存在关联。这提示了一个潜在的反馈循环:高强度认知建设期后,容易伴随对产出和落地的焦虑。目前这种情绪尚不构成底色,但值得在未来留意其触发条件。 + +#### **四、 结论与展望性提示** + +通过这46天的日志,您已经清晰地构建并记录了一条 **“感知-建模-应用”的认知演化路径**。您已经从单纯的记录者,成长为一位主动构建个人思想和知识系统的**架构师**。 + +基于这个分析,一些未来可能值得探索的方向是: +* **连接沉寂的过去**:“知识”、“工具”等早期概念在近期沉寂。当新的“建模”和“平台”概念遇到落地困难时,回顾并整合此前对这些基础工具和知识管理的思考,可能会带来新的解决方案。 +* **直面“困惑”的导航价值**:既然已经建立了强大的“启发”捕捉系统,是否可以同样系统地处理“困惑”?给“困惑”打标签、分类、追踪,可能会成为下一个层次的认知增长点。 +* **深化“人类-AI”模型**:“AI”和“人类”的共现关系已建立。把这个对比模型从哲学思考应用到更具体的领域,如“AI视角下的标准”vs“人类视角下的标准”,可能会在您的产品化思考中产生独特的设计洞见。 + +这份报告是对您过去46天心智旅程的一次回望。其中最宝贵的资产,正是日志中所展现的那种持续、敏锐、并不断尝试自我超越的思维习惯本身。 diff --git a/examples/default/prd.md b/examples/default/prd.md new file mode 100644 index 00000000..76a84f6d --- /dev/null +++ b/examples/default/prd.md @@ -0,0 +1,146 @@ +# 日志文本分析工具 — 产品需求文档 + +## 1. 概述 + +### 1.1 产品定位 +个人思维日志的轻量级文本分析工具,帮助用户从高密度、非结构化的日常思维流中提取可回顾的模式和趋势。 + +### 1.2 目标用户 +本人。使用场景是每天记录高密度思维流日志,需要通过程序来发现隐藏在时间线中的认知轨迹。 + +### 1.3 核心价值 +人很难回头翻阅几十天的日志。本工具将日志从"写了就放着的记录"变成"可回顾、可追踪的认知档案"。 + +## 2. 功能需求 + +### 2.1 基础词频统计 + +| 项 | 说明 | +|---|---| +| 输入 | 全部日志文件 | +| 输出 | 按词频降序排列的前 50 个词,附带每个词的出现天数 | +| 用途 | 快速感知一段时间内的关注重心,如"AI" 39 天说明几乎每天都涉及 | + +### 2.2 概念趋势追踪 + +| 项 | 说明 | +|---|---| +| 输入 | 单个中文概念(如"认知"、"管理") | +| 输出 | 该词在时间线上的频率变化图,附带首次/最近出现日期 | +| 用途 | 观察一个想法何时产生、何时爆发、何时消退 | + +### 2.3 突发词检测 + +| 项 | 说明 | +|---|---| +| 输入 | 全部日志 | +| 输出 | 每天中相对前 7 天突然高频出现的词 | +| 阈值 | 频率 ≥ 前 7 天均值的 2 倍,且当天出现 ≥ 3 次 | +| 用途 | 发现注意力的突然转向或新灵感的切入点 | + +### 2.4 共现词分析 + +| 项 | 说明 | +|---|---| +| 输入 | 单个中文概念 | +| 输出 | 与该词在 10 词窗口内最常共现的词语及比例 | +| 用途 | 揭示思维中概念的隐性关联结构 | + +### 2.5 每日关键词提取 + +| 项 | 说明 | +|---|---| +| 输入 | 全部日志 | +| 输出 | 每天 TF-IDF 权重最高的 10 个关键词 | +| 用途 | 快速浏览每天的思考主题摘要 | + +### 2.6 综合分析报告 + +| 项 | 说明 | +|---|---| +| 输入 | 全部日志 | +| 输出 | markdown 格式的分析报告文件 | +| 包含内容 | 日志概况、词频 Top 30、每日关键词、主要概念趋势、突发词记录 | +| 用途 | 生成可存档的周/月回顾素材 | + +## 3. 待补充分析维度 + +当前版本以词频统计和突发检测为主,对思维过程做的是"关键词素描"——勾勒了关注重心和注意力转向的时间点。以下维度是识别出的分析盲区,待后续迭代覆盖。 + +### 3.1 关联网络分析 + +| 项 | 说明 | +|---|---| +| 问题 | 当前高频词和突发词是孤立点,看不出词与词之间的结构关系 | +| 需求 | 对每天或全量日志构建词共现网络,提取高频共现边和桥梁词 | +| 用途 | 区分并行的思维支线(如"建模"常与"流程""标准"共现是构建线,"发现"与"感觉""认知"共现是顿悟线);识别连接不同领域的关键桥梁词(如"平台"同时出现在技术和组织语境) | + +### 3.2 情绪与态度倾向 + +| 项 | 说明 | +|---|---| +| 问题 | 报告不含情感极性,无法判断高频词背后的情绪状态 | +| 需求 | 引入情感词典或预训练模型,对每天日志做情感评分,绘制情绪波动曲线 | +| 用途 | 区分焦虑型"问题"和好奇型"问题";发现认知失调日(如 3/29 的"盲人摸象")与正面高峰日(如 4/6 的"愉悦感"),揭示思考背后的心理能量状态 | + +### 3.3 主题演化与上下文漂移 + +| 项 | 说明 | +|---|---| +| 问题 | "概念趋势"只统计单词频率变化,忽略了同一个词在不同时期的语义漂移 | +| 需求 | 对指定概念做分阶段的上下文词云,观察其关联词随时间的变迁 | +| 用途 | 以"AI"为例:早期与"模型""DeepSeek"绑定(工具测试)→ 中期与"写作""编程"关联(应用)→ 后期与"产品""建模"同现(创造)。同理,"团队"从"业务""流程"过渡到"认知""透明""绩效",反映关注点从"事"到"人与制度"的迁移 | + +### 3.4 沉寂与消退检测 + +| 项 | 说明 | +|---|---| +| 问题 | 突发词只捕捉"新出现",不反映"什么在消失" | +| 需求 | 检测连续多天缺席的衰退词,以及用户指定的重要词在时间线上的空白期 | +| 用途 | 早期高频的"知识库"后来消失——是内化为习惯还是放弃?缺席本身暗示决策、项目中断或认知完成 | + +### 3.5 写作行为元特征 + +| 项 | 说明 | +|---|---| +| 问题 | 报告不涉及"记录行为"本身的模式 | +| 需求 | 统计每日产量、写作时段(需带时间戳的日志)、句式复杂度、疑问句比例、第一人称比例等 | +| 用途 | 发现认知高潮后的低输出消化期;判断从"记录"到"自言自语"的表达风格演变——这是思维深化的信号 | + +### 3.6 长尾重要词提取 + +| 项 | 说明 | +|---|---| +| 问题 | 每日关键词只取 Top 5,大量低频但高信息密度的具体词被忽略 | +| 需求 | 在报告中增加"拾遗"板块,提取每天出现 1-2 次但高度具体的名词(如"麻辣烫""罚钱""宣传册") | +| 用途 | 这些词像"街头摄影",记载了具体的生活瞬间或真实事件,与抽象思考互补,拼出更完整的事件地图 | + +## 4. 非功能需求 + +| 项 | 要求 | +|---|---| +| 依赖 | Python 3, jieba, python-dotenv | +| 数据目录 | 可配置,通过 `.env` 文件指定 | +| 输出目录 | 可配置,通过 `.env` 文件指定 | +| 语言 | 中文(分词、停用词、输出均面向中文日志) | +| 性能 | 46 天日志分析应在 3 秒内完成 | + +## 5. 配置 + +```env +JOURNAL_DIR=<日志文件所在目录> +REPORT_DIR=<报告输出目录> +``` + +## 6. 用户交互 + +命令行参数驱动,无交互式输入。 + +| 命令 | 功能 | +|---|---| +| `python text_analysis.py` | 基础词频统计 | +| `python text_analysis.py --trend <词>` | 概念趋势追踪 | +| `python text_analysis.py --burst` | 突发词检测 | +| `python text_analysis.py --cooccur <词>` | 共现词分析 | +| `python text_analysis.py --topic` | 每日关键词 | +| `python text_analysis.py --report` | 生成综合分析报告 | diff --git a/examples/default/text_analysis.py b/examples/default/text_analysis.py new file mode 100644 index 00000000..0db372b8 --- /dev/null +++ b/examples/default/text_analysis.py @@ -0,0 +1,604 @@ +""" +日志文本分析工具 +分析 docs/archive/journal/default/ 下的每日思维流日志。 + +用法: + python text_analysis.py # 基本词频分析 + python text_analysis.py --trend 认知 # 追踪某个概念随时间的变化 + python text_analysis.py --burst # 检测突发性高频词 + python text_analysis.py --cooccur 管理 # 与某个词共现的词 + python text_analysis.py --topic # 每日主题摘要(TF-IDF 关键词) + python text_analysis.py --network # 关联网络:桥梁词发现 + python text_analysis.py --decay # 沉寂词检测:什么在消失 + python text_analysis.py --meta # 写作行为元特征 + python text_analysis.py --tails # 长尾重要词拾遗 + python text_analysis.py --sentiment # 情绪倾向概览 + python text_analysis.py --drift 概念 # 语义漂移:概念上下文变迁 +""" + +import os +import re +import sys +import math +from collections import Counter, defaultdict +from datetime import datetime + +import jieba +import jieba.analyse +from dotenv import load_dotenv + +load_dotenv() + +DATA_DIR = os.getenv("JOURNAL_DIR") +REPORT_DIR = os.getenv("REPORT_DIR", ".") +STOP_WORDS = { + "这个", "那个", "什么", "怎么", "如何", "可以", "就是", "一个", + "没有", "不是", "我们", "他们", "你们", "自己", "因为", "所以", + "但是", "如果", "而且", "或者", "然后", "已经", "可以", "知道", + "觉得", "这样", "那么", "时候", "还是", "这些", "一些", "不会", + "可能", "只是", "非常", "比较", "需要", "应该", "不要", "通过", + "之后", "之前", "现在", "今天", "已经", "还有", "对于", "当中", + "其实", "这种", "那个", "这个", "就是", "的话", "一种", "一下", + "开始", "之后", "之后", "方式", "来说", "看到", "到了", "有关", + "其中", "所谓", "是否", "能够", "成为", "带来", "提出", "进入", + "出来", "过来", "起来", "回到", "变得", "作为", "作为", "不同", + "一样", "一点", "越来越", "越来越", "越来越", +} + +POS_WORDS = { + "好", "不错", "愉快", "快乐", "开心", "期待", "满意", "突破", "成功", + "进步", "成长", "希望", "信心", "积极", "乐观", "清晰", "稳定", "成熟", + "顺利", "轻松", "舒适", "享受", "喜欢", "感动", "兴奋", "恭喜", "成就", + "收获", "领悟", "发现", "惊喜", "信任", "支持", "帮助", "提升", "改善", + "优雅", "丰富", "认可", "赞赏", "自豪", "从容", "扎实", "靠谱", "高效", + "通透", "通透", "明确", "灵活", "主动", "负责", "精彩", "了不起", + "愉悦", "畅快", "鼓舞", "振奋", "有价值", +} + +NEG_WORDS = { + "问题", "困难", "麻烦", "焦虑", "压力", "烦躁", "疲惫", "累", "沮丧", + "失望", "担心", "害怕", "紧张", "混乱", "模糊", "冲突", "矛盾", "失败", + "错误", "崩溃", "失控", "痛苦", "孤独", "无聊", "消极", "悲观", "批评", + "惩罚", "罚", "痛", "累死", "受不了", "头疼", "难受", "不安", "忧虑", + "纠结", "折磨", "压抑", "沉重", "憋屈", "尴尬", "厌倦", "反感", "抵触", + "隐患", "风险", "危机", "障碍", "瓶颈", "缺陷", "漏洞", "困扰", "阻碍", + "瓶颈", "麻烦", "危险", "脆弱", "失控", "失调", "恐慌", "急躁", "冲动", + "怀疑", "排斥", "抗拒", "阻力", "拖累", "浪费", "徒劳", +} + +EMOTION_CATEGORIES = { + "成就/掌控": {"突破", "成功", "掌控", "驾驭", "搞定", "完成", "达成", "实现", "确定", "稳定"}, + "困惑/混沌": {"困惑", "模糊", "混乱", "矛盾", "纠结", "迷茫", "复杂", "想不通", "不确定"}, + "压力/焦虑": {"焦虑", "压力", "紧张", "担心", "害怕", "不安", "恐慌", "急躁", "压迫"}, + "启发/顿悟": {"发现", "领悟", "通透", "理解", "明白", "意识到", "想通", "灵感", "突破", "认知"}, + "疲惫/倦怠": {"疲惫", "累", "累死", "倦怠", "疲劳", "透支", "乏力", "困", "厌倦", "无聊"}, + "信任/期待": {"信任", "期待", "希望", "信心", "认可", "支持", "相信", "盼望"}, + "批判/不满": {"批评", "惩罚", "罚", "抵触", "反感", "怀疑", "排斥", "不满", "质疑"}, + "建设/推进": {"改进", "优化", "重构", "迭代", "推进", "落地", "落实", "执行", "建立"}, +} + + +def load_journals(): + """加载所有日志文件,返回 {date_str: text} 字典""" + journals = {} + pattern = re.compile(r"^\d{4}-\d{2}-\d{2}\.md$") + for fname in sorted(os.listdir(DATA_DIR)): + if not pattern.match(fname): + continue + date_str = fname.replace(".md", "") + with open(os.path.join(DATA_DIR, fname), encoding="utf-8") as f: + text = f.read() + text = re.sub(r"^# .+", "", text, flags=re.MULTILINE) + journals[date_str] = text.strip() + return journals + + +def segment(text): + """分词并过滤停用词""" + words = jieba.cut(text) + return [w.strip() for w in words if w.strip() and len(w.strip()) > 1 and w.strip() not in STOP_WORDS] + + +def basic_stats(): + """基础统计:总词频""" + journals = load_journals() + counter = Counter() + for date_str, text in journals.items(): + words = segment(text) + counter.update(words) + print(f"日志总数: {len(journals)} 天") + print(f"去重词数: {len(counter)}") + print(f"\n总词频 Top 50:") + print(f"{'词':<10} {'频次':<6} {'出现天数':<8}") + print("-" * 30) + for word, count in counter.most_common(50): + day_count = sum(1 for text in journals.values() if word in text) + print(f"{word:<10} {count:<6} {day_count:<8}") + + +def daily_keywords(top_n=10): + """每日 TF-IDF 关键词""" + journals = load_journals() + for date_str, text in journals.items(): + if not text.strip(): + continue + keywords = jieba.analyse.extract_tags(text, topK=top_n, withWeight=True) + words = ", ".join(f"{w}({v:.2f})" for w, v in keywords) + print(f"{date_str}: {words}") + + +def trend(concept): + """追踪概念随时间的变化频率""" + journals = load_journals() + dates = [] + freqs = [] + for date_str, text in sorted(journals.items()): + if not text.strip(): + continue + words = segment(text) + count = words.count(concept) + if count > 0: + dates.append(date_str) + freqs.append(count) + if not dates: + print(f"未找到概念: {concept}") + return + max_freq = max(freqs) + bar_len = 40 + print(f"概念「{concept}」出现趋势 (共 {sum(freqs)} 次, {len(dates)} 天):\n") + for d, f in zip(dates, freqs): + bar = "█" * int(f / max_freq * bar_len) if max_freq else "" + print(f"{d} │{bar:<{bar_len}} {f}") + print(f"\n首次出现: {dates[0]}") + print(f"最近出现: {dates[-1]}") + + +def burst_detection(window=7, threshold=2.0): + """突发词检测:某词在某天的频率超过其平均频率的 threshold 倍""" + journals = load_journals() + date_list = sorted(journals.keys()) + word_daily = {} + for date_str in date_list: + words = segment(journals[date_str]) + word_daily[date_str] = Counter(words) + all_words = set() + for c in word_daily.values(): + all_words.update(c.keys()) + + print(f"突发词检测 (窗口={window}天, 阈值={threshold}x):\n") + for date_str in date_list: + today = word_daily[date_str] + window_start = max(0, date_list.index(date_str) - window) + window_dates = date_list[window_start:date_list.index(date_str)] + if not window_dates: + continue + window_counter = Counter() + for wd in window_dates: + window_counter.update(word_daily[wd]) + for word, count in today.most_common(50): + avg = window_counter.get(word, 0) / len(window_dates) if window_dates else 0 + if avg > 0 and count >= avg * threshold and count >= 3: + print(f"{date_str} 爆发: {word} ({count}次, 平时均{avg:.1f})") + + +def cooccur(concept, top_n=20): + """与目标词共现频率最高的词""" + journals = load_journals() + counter = Counter() + for text in journals.values(): + if concept not in text: + continue + words = segment(text) + idxs = [i for i, w in enumerate(words) if w == concept] + window = 10 + for idx in idxs: + start = max(0, idx - window) + end = min(len(words), idx + window + 1) + for w in words[start:end]: + if w != concept: + counter[w] += 1 + total = sum(counter.values()) + print(f"与「{concept}」最常共现的词 (总共现 {total} 次):\n") + print(f"{'词':<10} {'共现':<6} {'比例':<8}") + print("-" * 30) + for word, count in counter.most_common(top_n): + pct = count / total * 100 + print(f"{word:<10} {count:<6} {pct:.1f}%") + + +def classify_context(window_words): + """对一段窗口词列表做情绪分类""" + pos = sum(1 for w in window_words if w in POS_WORDS) + neg = sum(1 for w in window_words if w in NEG_WORDS) + if pos > neg * 2: + return "积极" + if neg > pos * 2: + return "消极" + return "中性" + + +def network_analysis(top_n=12): + """关联网络分析:按情绪上下文分类的共现关系""" + journals = load_journals() + pair_sentiment = defaultdict(lambda: {"积极": 0, "消极": 0, "中性": 0}) + word_counter = Counter() + for text in journals.values(): + words = segment(text) + word_set = set(words) + for w in word_set: + word_counter[w] += 1 + for w in word_set: + idxs = [i for i, x in enumerate(words) if x == w] + for idx in idxs: + start = max(0, idx - 8) + end = min(len(words), idx + 9) + ctx = words[start:idx] + words[idx+1:end] + tag = classify_context(ctx) + for other in word_set: + if other != w and other in ctx: + pair_sentiment[(min(w, other), max(w, other))][tag] += 1 + + print("关联网络:按情绪分类的共现关系\n") + for label, sentiment in [("积极关系", "积极"), ("消极关系", "消极"), ("中性关系", "中性")]: + pairs = [(a, b, c[sentiment]) for (a, b), c in pair_sentiment.items() if c[sentiment] > 0] + pairs.sort(key=lambda x: -x[2]) + print(f"\n{label}:\n") + print(f"{'词A':<10} {'词B':<10} {'频次':<6}") + print("-" * 30) + for a, b, cnt in pairs[:top_n]: + total = sum(c["积极"] + c["消极"] + c["中性"] for (x, y), c in pair_sentiment.items() if (x, y) == (a, b)) + print(f"{a:<10} {b:<10} {cnt}/{total}") + + bridge_counter = Counter() + for (a, b), c in pair_sentiment.items(): + total = c["积极"] + c["消极"] + c["中性"] + bridge_counter[a] += total + bridge_counter[b] += total + bridges = [(w, n) for w, n in bridge_counter.most_common(30) if word_counter[w] >= 5] + print(f"\n桥梁词:\n") + print(f"{'词':<10} {'连接强度':<8}") + print("-" * 20) + for w, n in bridges[:top_n]: + print(f"{w:<10} {n:<8}") + + +def decay_detection(window=7, gap=3): + """沉寂词检测:早期高频但在近期连续多日消失的词""" + journals = load_journals() + date_list = sorted(journals.keys()) + if len(date_list) < window + gap: + print("日志天数不足") + return + early = date_list[:window] + recent = date_list[-gap:] + early_counter = Counter() + for d in early: + early_counter.update(segment(journals[d])) + recent_words = set() + for d in recent: + recent_words.update(segment(journals[d])) + h = f"前{window}天频次" + print(f"沉寂词检测 (前{window}天高频, 后{gap}天未出现):\n") + print(f"{'词':<10} {h:<12} {'总频次':<8}") + print("-" * 35) + count = 0 + for word, freq in early_counter.most_common(50): + if word not in recent_words: + total = sum(1 for t in journals.values() if word in t) + print(f"{word:<10} {freq:<12} {total:<8}") + count += 1 + if count >= 15: + break + + +def meta_stats(): + """写作行为元特征:统计每日产出模式""" + journals = load_journals() + date_list = sorted(journals.keys()) + lengths = [] + for d in date_list: + text = journals[d] + paras = [p for p in text.split("\n\n") if p.strip()] + sentences = [s for s in re.split(r"[。!?\n]", text) if s.strip()] + words = segment(text) + lengths.append((d, len(text), len(words), len(sentences), len(paras))) + total_chars = sum(l[1] for l in lengths) + print(f"写作行为元特征 ({date_list[0]} ~ {date_list[-1]})\n") + print(f"日均字数: {total_chars // len(lengths)}") + print(f"日均词数: {sum(l[2] for l in lengths) // len(lengths)}") + print(f"最产出日: {max(lengths, key=lambda x: x[1])[0]} ({max(l[1] for l in lengths)}字)") + print(f"最简短日: {min(lengths, key=lambda x: x[1])[0]} ({min(l[1] for l in lengths)}字)") + print(f"\n每日产量趋势 (字):\n") + max_len = max(l[1] for l in lengths) + bar_len = 30 + for d, chars, nwords, nsent, npara in lengths: + bar = "█" * int(chars / max_len * bar_len) if max_len else "" + print(f"{d} │{bar:<{bar_len}} {chars}字") + print(f"\n高产日 (超过日均2倍):") + avg = total_chars / len(lengths) + for d, chars, _, _, _ in lengths: + if chars > avg * 2: + print(f" {d} ({chars}字)") + + +def long_tails(): + """长尾重要词拾遗:低频但跨天出现,往往是具体事件或事物""" + journals = load_journals() + total_counter = Counter() + day_counter = Counter() + for text in journals.values(): + words = set(segment(text)) + for w in words: + day_counter[w] += 1 + for w in segment(text): + total_counter[w] += 1 + print("长尾词拾遗(低频但跨天出现,往往是具体事件或事物):\n") + print(f"{'词':<10} {'天数':<6} {'总频次':<8}") + print("-" * 28) + candidates = [(w, day_counter[w], total_counter[w]) for w in day_counter + if 2 <= day_counter[w] <= 4 and total_counter[w] <= 6] + candidates.sort(key=lambda x: -x[1]) + for w, days, total in candidates[:30]: + print(f"{w:<10} {days:<6} {total:<8}") + print(f"\n独家词(仅在某一天出现,高度偶发):\n") + once = [(w, total_counter[w]) for w in day_counter if day_counter[w] == 1] + once.sort(key=lambda x: -x[1]) + for w, total in once[:20]: + print(f" {w}") + + +def sentiment_summary(): + """情绪倾向概览:多维度情绪分类""" + journals = load_journals() + date_list = sorted(journals.keys()) + daily_emotions = [] + for d in date_list: + words = segment(journals[d]) + categories = Counter() + for w in words: + for cat, cat_words in EMOTION_CATEGORIES.items(): + if w in cat_words: + categories[cat] += 1 + daily_emotions.append((d, categories)) + + total_by_cat = Counter() + for _, cats in daily_emotions: + total_by_cat.update(cats) + + print(f"情绪倾向概览 ({date_list[0]} ~ {date_list[-1]})\n") + print("各情绪维度总频次:\n") + for cat, count in total_by_cat.most_common(): + print(f" {cat}: {count}") + print(f"\n情绪时间线 (逐日最显著维度):\n") + print(f"{'日期':<12} {'主导情绪':<16} {'得分':<6}") + print("-" * 40) + for d, cats in daily_emotions: + if cats: + top_cat, top_score = cats.most_common(1)[0] + bar = "■" * min(top_score, 10) + print(f"{d:<12} {top_cat:<16} {bar} {top_score}") + + +def drift(concept): + """语义漂移:概念在不同阶段的上下文词变化""" + journals = load_journals() + date_list = sorted(journals.keys()) + n = len(date_list) + if n < 6: + print("日志天数不足") + return + third = n // 3 + stages = [ + ("早期", date_list[:third]), + ("中期", date_list[third:2*third]), + ("近期", date_list[2*third:]), + ] + print(f"概念「{concept}」语义漂移 (上下文词 Top 10):\n") + for label, dates in stages: + counter = Counter() + for d in dates: + text = journals[d] + if concept not in text: + continue + words = segment(text) + idxs = [i for i, w in enumerate(words) if w == concept] + for idx in idxs: + start = max(0, idx - 8) + end = min(len(words), idx + 9) + for w in words[start:end]: + if w != concept and w not in STOP_WORDS: + counter[w] += 1 + top = [w for w, _ in counter.most_common(10)] + print(f" {label}: {', '.join(top) if top else '(未出现)'}") + + +def generate_report(): + """生成综合分析报告,输出到 REPORT_DIR""" + journals = load_journals() + date_list = sorted(journals.keys()) + + counter = Counter() + for text in journals.values(): + counter.update(segment(text)) + + word_daily = {} + for d in date_list: + word_daily[d] = Counter(segment(journals[d])) + + h = "前7天频次" + + lines = [] + lines.append("# 日志分析报告\n") + lines.append(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n") + lines.append(f"日志天数: {len(journals)} 天 ({date_list[0]} ~ {date_list[-1]})\n") + lines.append(f"去重词数: {len(counter)}\n") + lines.append("---\n") + + lines.append("## 总词频 Top 30\n\n") + lines.append("| 词 | 频次 | 出现天数 |\n") + lines.append("|---|---|---|\n") + for word, count in counter.most_common(30): + day_count = sum(1 for t in journals.values() if word in t) + lines.append(f"| {word} | {count} | {day_count} |\n") + + lines.append("\n## 每日关键词\n\n") + for d in date_list: + keywords = jieba.analyse.extract_tags(journals[d], topK=5, withWeight=True) + words = ", ".join(f"{w}({v:.2f})" for w, v in keywords) + lines.append(f"- **{d}**: {words}\n") + + top_concepts = [w for w, _ in counter.most_common(20)] + lines.append("\n## 概念趋势\n\n") + for concept in top_concepts[:10]: + freq_by_day = [(d, word_daily[d][concept]) for d in date_list if word_daily[d][concept] > 0] + if len(freq_by_day) < 2: + continue + total = sum(f for _, f in freq_by_day) + first = freq_by_day[0][0] + last = freq_by_day[-1][0] + lines.append(f"- **{concept}**: 共 {total} 次, {len(freq_by_day)} 天, 首现 {first}, 最近 {last}\n") + + lines.append("\n## 突发词(按天)\n\n") + for i, d in enumerate(date_list): + today = word_daily[d] + window_start = max(0, i - 7) + if i == window_start: + continue + window_dates = date_list[window_start:i] + window_counter = Counter() + for wd in window_dates: + window_counter.update(word_daily[wd]) + bursts = [] + for word, count in today.most_common(30): + avg = window_counter.get(word, 0) / len(window_dates) if window_dates else 0 + if avg > 0 and count >= avg * 2 and count >= 3: + bursts.append(f"{word}({count}/{avg:.1f})") + if bursts: + lines.append(f"- **{d}**: {', '.join(bursts)}\n") + + lines.append("\n## 关联网络:按情绪分类的共现关系\n\n") + pair_sentiment = defaultdict(lambda: {"积极": 0, "消极": 0, "中性": 0}) + for text in journals.values(): + words = segment(text) + word_set = set(words) + for w in word_set: + idxs = [i for i, x in enumerate(words) if x == w] + for idx in idxs: + start = max(0, idx - 8) + end = min(len(words), idx + 9) + ctx = words[start:idx] + words[idx+1:end] + tag = classify_context(ctx) + for other in word_set: + if other != w and other in ctx: + pair_sentiment[(min(w, other), max(w, other))][tag] += 1 + for label, sentiment in [("积极关系", "积极"), ("消极关系", "消极"), ("中性关系", "中性")]: + pairs = [(a, b, c[sentiment]) for (a, b), c in pair_sentiment.items() if c[sentiment] >= 3] + pairs.sort(key=lambda x: -x[2]) + if pairs: + lines.append(f"### {label}\n\n") + lines.append("| 词A | 词B | 出现次数 |\n") + lines.append("|---|---|---|\n") + for a, b, cnt in pairs[:10]: + lines.append(f"| {a} | {b} | {cnt} |\n") + + lines.append("\n## 情绪倾向(按维度的逐日分布)\n\n") + daily_emotions = [] + for d in date_list: + words = segment(journals[d]) + cats = Counter() + for w in words: + for cat_name, cat_words in EMOTION_CATEGORIES.items(): + if w in cat_words: + cats[cat_name] += 1 + daily_emotions.append((d, cats)) + total_by_cat = Counter() + for _, cats in daily_emotions: + total_by_cat.update(cats) + lines.append("各情绪维度总频次:\n\n") + for cat, count in total_by_cat.most_common(): + lines.append(f"- {cat}: {count}\n") + lines.append(f"\n情绪时间线 (逐日主导维度):\n\n") + for d, cats in daily_emotions: + if cats: + top_cat, top_score = cats.most_common(1)[0] + lines.append(f"- {d}: {top_cat} ({top_score})\n") + + lines.append("\n## 沉寂词(前7天高频但近期消失)\n\n") + early = date_list[:7] + recent = date_list[-3:] + early_counter = Counter() + for d in early: + early_counter.update(segment(journals[d])) + recent_words = set() + for d in recent: + recent_words.update(segment(journals[d])) + for word, freq in early_counter.most_common(50): + if word not in recent_words: + total = sum(1 for t in journals.values() if word in t) + lines.append(f"- **{word}**: 前7天 {freq} 次, 共 {total} 天\n") + + lines.append("\n## 长尾词拾遗\n\n") + day_counter = Counter() + total_counter = Counter() + for text in journals.values(): + for w in set(segment(text)): + day_counter[w] += 1 + for w in segment(text): + total_counter[w] += 1 + tails = [(w, day_counter[w]) for w in day_counter if 2 <= day_counter[w] <= 4 and total_counter[w] <= 6] + tails.sort(key=lambda x: -x[1]) + for w, d in tails[:15]: + lines.append(f"- {w} ({d}天)\n") + + lines.append("\n## 写作行为元特征\n\n") + lengths = [] + for d in date_list: + words = segment(journals[d]) + lengths.append((d, len(journals[d]), len(words))) + avg_chars = sum(l[1] for l in lengths) // len(lengths) + avg_words = sum(l[2] for l in lengths) // len(lengths) + max_day = max(lengths, key=lambda x: x[1]) + min_day = min(lengths, key=lambda x: x[1]) + lines.append(f"- 日均字数: {avg_chars}\n") + lines.append(f"- 日均词数: {avg_words}\n") + lines.append(f"- 最产出日: {max_day[0]} ({max_day[1]}字)\n") + lines.append(f"- 最简短日: {min_day[0]} ({min_day[1]}字)\n") + + os.makedirs(REPORT_DIR, exist_ok=True) + path = os.path.join(REPORT_DIR, "journal_report.md") + with open(path, "w", encoding="utf-8") as f: + f.writelines(lines) + print(f"报告已生成: {path}") + + +def main(): + if len(sys.argv) == 1: + basic_stats() + elif sys.argv[1] == "--trend" and len(sys.argv) > 2: + trend(sys.argv[2]) + elif sys.argv[1] == "--burst": + burst_detection() + elif sys.argv[1] == "--cooccur" and len(sys.argv) > 2: + cooccur(sys.argv[2]) + elif sys.argv[1] == "--topic": + daily_keywords() + elif sys.argv[1] == "--report": + generate_report() + elif sys.argv[1] == "--network": + network_analysis() + elif sys.argv[1] == "--decay": + decay_detection() + elif sys.argv[1] == "--meta": + meta_stats() + elif sys.argv[1] == "--tails": + long_tails() + elif sys.argv[1] == "--sentiment": + sentiment_summary() + elif sys.argv[1] == "--drift" and len(sys.argv) > 2: + drift(sys.argv[2]) + else: + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/examples/human/__init__.py b/examples/human/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/human/classifier.py b/examples/human/classifier.py new file mode 100644 index 00000000..ca2e7b27 --- /dev/null +++ b/examples/human/classifier.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass + +STATUS_KEYWORDS = { + "contacted": ["应聘", "求职", "简历", "申请"], + "exam_sent": ["笔试邀请", "笔试通知", "在线考试"], + "exam_received": ["笔试答案", "答题", "笔试完成", "提交答卷"], + "evaluating": ["评估", "审核简历", "简历评估"], + "interview": ["面试感谢", "面试反馈", "面试安排", "面试邀请"], + "offer": ["offer", "录用通知", "入职邀请", "薪酬确认"], + "closed": ["放弃", "退出", "拒绝", "不考虑"], +} + +@dataclass +class ClassificationResult: + suggested_status: str | None + confidence: str + suggested_position: str | None + extracted_name: str | None + extracted_email: str | None + extracted_phone: str | None + +def classify(subject: str, sender_name: str, sender_email: str) -> ClassificationResult: + subject_lower = subject.lower() + suggested_status = None; confidence = "low" + matched_keywords = [] + for status, keywords in STATUS_KEYWORDS.items(): + for kw in keywords: + if kw in subject_lower: + matched_keywords.append((status, kw)) + if matched_keywords: + status_groups = {} + for s, _ in matched_keywords: + status_groups[s] = status_groups.get(s, 0) + 1 + suggested_status = max(status_groups, key=status_groups.get) + confidence = "high" if status_groups[suggested_status] >= 2 else "medium" + extracted_name = sender_name if sender_name and sender_name != sender_email else None + return ClassificationResult(suggested_status, confidence, None, extracted_name, sender_email, None) diff --git a/examples/human/database.py b/examples/human/database.py new file mode 100644 index 00000000..745fbce0 --- /dev/null +++ b/examples/human/database.py @@ -0,0 +1,29 @@ +"""Database setup for example HR module.""" +from collections.abc import Generator +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "hr_demo.db") +DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def init_db() -> None: + import human.models # noqa: F401 + Base.metadata.create_all(bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/examples/human/demo.py b/examples/human/demo.py new file mode 100644 index 00000000..ad40e036 --- /dev/null +++ b/examples/human/demo.py @@ -0,0 +1,237 @@ +"""HR Demo — Standalone server with Feishu integration. + +整合了 quanttide-hr-toolkit-main 的完整 demo 架构: + - 招聘管道 API(所有 routers) + - 飞书邮箱轮询(`_poll_mailbox` 后台任务) + - 附件下载(lark-cli + httpx) + - 种子数据 + 数据库迁移 + - 静态前端 + +Usage: + cd qtadmin + QTADMIN_MAILBOX=xxx@example.com PYTHONPATH=src/provider src/provider/.venv/bin/python examples/human/demo.py +""" +import asyncio +import json +import os +import subprocess +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +import httpx +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware + +from app.human.database import SessionLocal, init_db +from app.human.models.processed_mail import ProcessedMail +from app.human.models.recruitment import Recruitment +from app.human.routers import ( + ai_config, applications, candidates, export, ingest, materials, messages, + pipeline, pool, queue, recruitments, +) + +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_DATA_DIR = os.environ.get("QTADMIN_DATA_DIR", os.path.join(_PROJECT_ROOT, "data")) +_ATTACHMENT_DIR = os.path.join(_DATA_DIR, "attachments") +_MATERIALS_DIR = os.path.join(_DATA_DIR, "materials") + + +def seed_data_if_empty(): + db = SessionLocal() + try: + exists = db.query(Recruitment).first() + if not exists: + from app.human.seed import seed_data + seed_data(db) + finally: + db.close() + + +def _download_attachment(message_id: str, attachment: dict, mailbox: str) -> str | None: + """Download attachment via lark-cli download_url, return local path.""" + att_id = attachment.get("message_attachment_id") + if not att_id: + return None + + cmd = [ + "lark-cli", "mail", "user_mailbox.message.attachments", "download_url", + "--params", json.dumps({ + "user_mailbox_id": mailbox or "me", + "message_id": message_id, + "attachment_ids": [att_id], + }), + "--format", "json", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + result.check_returncode() + resp = json.loads(result.stdout) + urls = resp.get("data", {}).get("download_urls", []) + if not urls: + return None + download_url = urls[0].get("download_url", "") + if not download_url: + return None + except Exception: + return None + + storage_dir = os.path.join(_ATTACHMENT_DIR, message_id) + os.makedirs(storage_dir, exist_ok=True) + file_path = os.path.join(storage_dir, attachment["filename"]) + + try: + r = httpx.get(download_url, timeout=60, follow_redirects=True) + r.raise_for_status() + with open(file_path, "wb") as f: + f.write(r.content) + attachment["size"] = len(r.content) + return file_path + except Exception: + return None + + +def _fetch_mail(mailbox: str) -> list[dict]: + from feishu_integration.mail_reader import fetch_and_classify, fetch_single_email + items = fetch_and_classify(mailbox=mailbox) + for item in items: + try: + detail = fetch_single_email(item["message_id"], mailbox=mailbox) + item["body"] = detail.get("body", "") + item["body_text"] = detail.get("body_plain_text", "") + item["recipient_email"] = detail.get("to", "") + attachments = [] + for a in detail.get("attachments", []): + att = { + "filename": a.get("filename", ""), + "size": a.get("size", 0), + "mime_type": a.get("content_type", ""), + "message_attachment_id": a.get("message_attachment_id") or a.get("id", ""), + } + if att["mime_type"] in ("application/pdf",) or att["filename"].endswith(".pdf"): + storage_path = _download_attachment(item["message_id"], att, mailbox) + if storage_path: + att["storage_path"] = storage_path + attachments.append(att) + item["attachments"] = attachments + except Exception: + pass + return items + + +async def _poll_mailbox(): + mailbox = os.environ.get("QTADMIN_MAILBOX", "") + if not mailbox: + return + while True: + try: + items = await asyncio.to_thread(_fetch_mail, mailbox) + db = SessionLocal() + try: + known = {row[0] for row in db.query(ProcessedMail.message_id).all()} + new_items = [it for it in items if it["message_id"] not in known] + for item in new_items: + db.add(ProcessedMail(message_id=item["message_id"])) + db.commit() + finally: + db.close() + if new_items: + payload = { + "source": "feishu_api", + "items": [ + { + "message_id": item["message_id"], + "subject": item["subject"], + "sender_name": item.get("sender_name", ""), + "sender_email": item["sender_email"], + "recipient_email": item.get("recipient_email", ""), + "suggested_status": item.get("suggested_status"), + "confidence": item.get("confidence", "low"), + "body": item.get("body"), + "body_text": item.get("body_text"), + "attachments": item.get("attachments"), + } + for item in new_items + ], + } + async with httpx.AsyncClient() as client: + resp = await client.post( + "http://localhost:8000/ingest", + json=payload, + timeout=30, + ) + resp.raise_for_status() + except Exception: + pass + await asyncio.sleep(300) + + +_poll_task: asyncio.Task | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + for d in [_ATTACHMENT_DIR, _MATERIALS_DIR]: + os.makedirs(d, exist_ok=True) + init_db() + seed_data_if_empty() + global _poll_task + _poll_task = asyncio.create_task(_poll_mailbox()) + yield + if _poll_task: + _poll_task.cancel() + + +app = FastAPI(title="HR Demo — 招聘管道看板", version="0.1.0", lifespan=lifespan) + + +@app.middleware("http") +async def no_cache(request, call_next): + response = await call_next(request) + if request.url.path in ("/",) or request.url.path.endswith((".html", ".js", ".css")): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return response + + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://127.0.0.1:8080", "http://localhost:8080", + "http://127.0.0.1:8081", "http://localhost:8081", + "http://127.0.0.1:8000", "http://localhost:8000", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(ai_config.router) +app.include_router(export.router) +app.include_router(materials.router) +app.include_router(messages.router) +app.include_router(ingest.router) +app.include_router(queue.router) +app.include_router(pipeline.router) +app.include_router(pool.router) +app.include_router(recruitments.router) +app.include_router(candidates.router) +app.include_router(applications.router) + + +@app.get("/attachments/{message_id}/{filename:path}") +def serve_attachment(message_id: str, filename: str): + """Serve stored attachment files for browser preview.""" + file_path = os.path.join(_ATTACHMENT_DIR, message_id, filename) + if not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Attachment not found") + return FileResponse(file_path, filename=filename) + + +static_dir = os.path.join(os.path.dirname(__file__), "static") +app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/human/models/__init__.py b/examples/human/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/human/models/application.py b/examples/human/models/application.py new file mode 100644 index 00000000..5069a0ce --- /dev/null +++ b/examples/human/models/application.py @@ -0,0 +1,21 @@ +from datetime import datetime +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from human.database import Base +from human.models.talent import TalentStatus + +class Application(Base): + __tablename__ = "applications" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + candidate_id: Mapped[int] = mapped_column(ForeignKey("candidates.id"), index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + candidate: Mapped["Candidate"] = relationship("Candidate", lazy="joined") + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True) + source: Mapped[str] = mapped_column(String(50), default="manual") + pooled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/examples/human/models/candidate.py b/examples/human/models/candidate.py new file mode 100644 index 00000000..a84156c6 --- /dev/null +++ b/examples/human/models/candidate.py @@ -0,0 +1,13 @@ +import enum +from datetime import datetime +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class Candidate(Base): + __tablename__ = "candidates" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/examples/human/models/pending_queue.py b/examples/human/models/pending_queue.py new file mode 100644 index 00000000..98c064fc --- /dev/null +++ b/examples/human/models/pending_queue.py @@ -0,0 +1,17 @@ +from datetime import datetime +from sqlalchemy import DateTime, String, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class PendingQueueItem(Base): + __tablename__ = "pending_queue" + __table_args__ = (UniqueConstraint("message_id"),) + id: Mapped[int] = mapped_column(primary_key=True, index=True) + message_id: Mapped[str] = mapped_column(String(100), unique=True, index=True) + subject: Mapped[str] = mapped_column(String(500)) + sender_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + sender_email: Mapped[str | None] = mapped_column(String(200), nullable=False) + suggested_status: Mapped[str | None] = mapped_column(String(30), nullable=True) + confidence: Mapped[str] = mapped_column(String(10), default="low") + hr_status: Mapped[str] = mapped_column(String(20), default="pending") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/examples/human/models/recruitment.py b/examples/human/models/recruitment.py new file mode 100644 index 00000000..88e931db --- /dev/null +++ b/examples/human/models/recruitment.py @@ -0,0 +1,9 @@ +from datetime import datetime +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class Recruitment(Base): + __tablename__ = "recruitments" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/examples/human/models/talent.py b/examples/human/models/talent.py new file mode 100644 index 00000000..49c9b153 --- /dev/null +++ b/examples/human/models/talent.py @@ -0,0 +1,44 @@ +import enum +from datetime import datetime +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class TalentStatus(str, enum.Enum): + NEW = "new" + CONTACTED = "contacted" + EXAM_SENT = "exam_sent" + EXAM_RECEIVED = "exam_received" + EVALUATING = "evaluating" + INTERVIEW = "interview" + OFFER = "offer" + CLOSED = "closed" + +ALLOWED_STATUSES_FOR_SUB_STAGE = { + TalentStatus.CONTACTED, TalentStatus.EXAM_SENT, + TalentStatus.EVALUATING, TalentStatus.INTERVIEW, TalentStatus.OFFER, +} + +STATUS_TRANSITIONS = { + TalentStatus.NEW: [TalentStatus.CONTACTED, TalentStatus.CLOSED], + TalentStatus.CONTACTED: [TalentStatus.EXAM_SENT, TalentStatus.CLOSED], + TalentStatus.EXAM_SENT: [TalentStatus.EXAM_RECEIVED, TalentStatus.CLOSED], + TalentStatus.EXAM_RECEIVED: [TalentStatus.EVALUATING, TalentStatus.CLOSED], + TalentStatus.EVALUATING: [TalentStatus.EXAM_SENT, TalentStatus.INTERVIEW, TalentStatus.CLOSED], + TalentStatus.INTERVIEW: [TalentStatus.OFFER, TalentStatus.CLOSED], + TalentStatus.OFFER: [TalentStatus.CLOSED], + TalentStatus.CLOSED: [], +} + +class Talent(Base): + __tablename__ = "talents" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/examples/human/routers/__init__.py b/examples/human/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/human/routers/applications.py b/examples/human/routers/applications.py new file mode 100644 index 00000000..a823dc46 --- /dev/null +++ b/examples/human/routers/applications.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.application import Application +from human.schemas.application import ApplicationRead + +router = APIRouter(prefix="/applications", tags=["human"]) + +@router.get("", response_model=list[ApplicationRead]) +def list_applications(status: str | None = None, pooled: bool | None = None, + skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db)): + qb = db.query(Application) + if status: qb = qb.filter(Application.status == status) + if pooled is True: qb = qb.filter(Application.pooled_at.isnot(None)) + elif pooled is False: qb = qb.filter(Application.pooled_at.is_(None)) + return qb.order_by(Application.created_at.desc()).offset(skip).limit(limit).all() diff --git a/examples/human/routers/candidates.py b/examples/human/routers/candidates.py new file mode 100644 index 00000000..7052e7ce --- /dev/null +++ b/examples/human/routers/candidates.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.candidate import Candidate +from human.models.application import Application +from human.schemas.candidate import CandidateRead +from human.schemas.application import ApplicationRead + +router = APIRouter(prefix="/candidates", tags=["human"]) + +@router.get("", response_model=list[CandidateRead]) +def list_candidates(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db)): + return db.query(Candidate).order_by(Candidate.created_at.desc()).offset(skip).limit(limit).all() + +@router.get("/{candidate_id}/applications", response_model=list[ApplicationRead]) +def get_candidate_applications(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: raise HTTPException(404, "Candidate not found") + return db.query(Application).filter(Application.candidate_id == candidate_id).all() diff --git a/examples/human/routers/ingest.py b/examples/human/routers/ingest.py new file mode 100644 index 00000000..8b810866 --- /dev/null +++ b/examples/human/routers/ingest.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.pending_queue import PendingQueueItem +from human.schemas.pending_queue import IngestRequest, IngestResponse + +router = APIRouter(tags=["human"]) + +@router.post("/ingest", status_code=201, response_model=IngestResponse) +def ingest_items(data: IngestRequest, db: Session = Depends(get_db)): + queued = 0; skipped = 0; errors = [] + for item in data.items: + exists = db.query(PendingQueueItem).filter(PendingQueueItem.message_id == item.message_id).first() + if exists: + skipped += 1; continue + qi = PendingQueueItem(message_id=item.message_id, subject=item.subject, + sender_name=item.sender_name, sender_email=item.sender_email, + suggested_status=item.suggested_status, confidence=item.confidence) + db.add(qi); queued += 1 + db.commit() + return IngestResponse(batch_id=None, queued=queued, skipped=skipped, errors=errors) diff --git a/examples/human/routers/pipeline.py b/examples/human/routers/pipeline.py new file mode 100644 index 00000000..d9881a14 --- /dev/null +++ b/examples/human/routers/pipeline.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from human.database import get_db +from human.services.pipeline import get_pipeline + +router = APIRouter(tags=["human"]) + +@router.get("/pipeline") +def pipeline_view(db: Session = Depends(get_db)): + return get_pipeline(db) diff --git a/examples/human/routers/pool.py b/examples/human/routers/pool.py new file mode 100644 index 00000000..be43b651 --- /dev/null +++ b/examples/human/routers/pool.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.application import Application +from human.services.pool import get_pooled_applications, pool_application, unpool_application +from human.schemas.application import PoolItemRead, UnpoolRequest + +router = APIRouter(prefix="/pool", tags=["human"]) + +def _pool_item_from_orm(app: Application) -> dict: + return { + "id": app.id, "candidate_id": app.candidate_id, "recruitment_id": app.recruitment_id, + "status": app.status.value, "source": app.source, + "pooled_at": app.pooled_at.isoformat() if app.pooled_at else None, + "deactivated_at": app.deactivated_at.isoformat() if app.deactivated_at else None, + "candidate_email": app.candidate.email if app.candidate else "", + "candidate_name": app.candidate.real_name if app.candidate else "", + } + +@router.get("", response_model=list[dict]) +def list_pool(db: Session = Depends(get_db)): + apps = get_pooled_applications(db) + return [_pool_item_from_orm(a) for a in apps] + +@router.post("/{application_id}/pool", response_model=dict) +def pool_app(application_id: int, db: Session = Depends(get_db)): + try: + app = pool_application(db, application_id) + return _pool_item_from_orm(app) + except ValueError as e: + raise HTTPException(404, str(e)) + +@router.post("/{application_id}/unpool", status_code=201, response_model=dict) +def unpool_app(application_id: int, data: UnpoolRequest, db: Session = Depends(get_db)): + try: + app = unpool_application(db, application_id, data.recruitment_id) + return _pool_item_from_orm(app) + except ValueError as e: + raise HTTPException(400, str(e)) diff --git a/examples/human/routers/queue.py b/examples/human/routers/queue.py new file mode 100644 index 00000000..646fbab1 --- /dev/null +++ b/examples/human/routers/queue.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.pending_queue import PendingQueueItem +from human.models.recruitment import Recruitment +from human.models.talent import Talent, TalentStatus +from human.models.candidate import Candidate +from human.models.application import Application +from human.schemas.pending_queue import ConfirmRequest, ConfirmResponse, IgnoreRequest + +router = APIRouter(prefix="/queue", tags=["human"]) + +@router.get("") +def list_queue(hr_status: str | None = None, db: Session = Depends(get_db)): + qb = db.query(PendingQueueItem).order_by(PendingQueueItem.created_at.desc()) + if hr_status: + qb = qb.filter(PendingQueueItem.hr_status == hr_status) + items = qb.all() + return {"total": len(items), "items": [{ + "queue_id": qi.id, "message_id": qi.message_id, + "subject": qi.subject, "sender_name": qi.sender_name, + "sender_email": qi.sender_email, "suggested_status": qi.suggested_status, + "confidence": qi.confidence, "hr_status": qi.hr_status, + "created_at": str(qi.created_at), + } for qi in items]} + +@router.patch("/{queue_id}/confirm", response_model=ConfirmResponse) +def confirm_queue_item(queue_id: int, data: ConfirmRequest, db: Session = Depends(get_db)): + qi = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not qi: + raise HTTPException(404, "Queue item not found") + qi.hr_status = "confirmed"; db.flush() + recruitment = db.query(Recruitment).order_by(Recruitment.created_at.desc()).first() + if not recruitment: + recruitment = Recruitment(); db.add(recruitment); db.flush() + email = data.email or qi.sender_email or "unknown@email.com" + name = data.real_name or qi.sender_name or email.split("@")[0] + status = TalentStatus(data.status) if data.status else TalentStatus.CONTACTED + candidate = db.query(Candidate).filter(Candidate.email == email).first() + if not candidate: + candidate = Candidate(email=email, real_name=name); db.add(candidate); db.flush() + app = Application(candidate_id=candidate.id, recruitment_id=recruitment.id, status=status, source="email_queue") + db.add(app); db.flush() + talent = Talent(recruitment_id=recruitment.id, email=email, real_name=name, status=status) + db.add(talent); db.commit(); db.refresh(talent) + return ConfirmResponse(queue_id=queue_id, action="confirmed", talent_id=talent.id) + +@router.patch("/{queue_id}/ignore", response_model=ConfirmResponse) +def ignore_queue_item(queue_id: int, data: IgnoreRequest = None, db: Session = Depends(get_db)): + qi = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not qi: + raise HTTPException(404, "Queue item not found") + qi.hr_status = "ignored"; db.commit() + return ConfirmResponse(queue_id=queue_id, action="ignored") + +@router.get("/stats") +def queue_stats(db: Session = Depends(get_db)): + from sqlalchemy import func + counts = db.query(PendingQueueItem.hr_status, func.count(PendingQueueItem.id)).group_by(PendingQueueItem.hr_status).all() + stats = {"pending": 0, "confirmed": 0, "ignored": 0} + for status, count in counts: + if status in stats: stats[status] = count + return stats + +@router.get("/by-email") +def get_queue_by_email(email: str, db: Session = Depends(get_db)): + qi = db.query(PendingQueueItem).filter(PendingQueueItem.sender_email == email).order_by(PendingQueueItem.created_at.desc()).first() + if not qi: + return {"found": False} + return {"found": True, "item": {"queue_id": qi.id, "message_id": qi.message_id, "subject": qi.subject, + "sender_name": qi.sender_name, "sender_email": qi.sender_email, + "suggested_status": qi.suggested_status, "confidence": qi.confidence, + "hr_status": qi.hr_status}} diff --git a/examples/human/routers/recruitments.py b/examples/human/routers/recruitments.py new file mode 100644 index 00000000..7adb54a5 --- /dev/null +++ b/examples/human/routers/recruitments.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus +from human.models.recruitment import Recruitment +from human.models.candidate import Candidate +from human.models.application import Application +from human.schemas.talent import SubStageUpdate, TalentCreate, TalentRead, TalentTransition, TalentUpdate +from human.schemas.recruitment import HeadcountRead, RecruitmentRead +from human.services.headcount import get_headcount + +router = APIRouter(prefix="/recruitments", tags=["human"]) + +@router.get("", response_model=list[RecruitmentRead]) +def list_recruitments(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db)): + return db.query(Recruitment).order_by(Recruitment.created_at.desc()).offset(skip).limit(limit).all() + +@router.get("/{recruitment_id}", response_model=RecruitmentRead) +def get_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: raise HTTPException(404, "Recruitment not found") + return r + +@router.post("", response_model=RecruitmentRead, status_code=201) +def create_recruitment(db: Session = Depends(get_db)): + r = Recruitment(); db.add(r); db.commit(); db.refresh(r); return r + +@router.delete("/{recruitment_id}", status_code=204) +def delete_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: raise HTTPException(404, "Recruitment not found") + db.delete(r); db.commit() + +@router.get("/{recruitment_id}/headcount", response_model=HeadcountRead) +def get_recruitment_headcount(recruitment_id: int, db: Session = Depends(get_db)): + if not db.query(Recruitment).filter(Recruitment.id == recruitment_id).first(): + raise HTTPException(404, "Recruitment not found") + return get_headcount(db, recruitment_id) + +@router.get("/{recruitment_id}/talents", response_model=list[TalentRead]) +def list_talents(recruitment_id: int, status: TalentStatus | None = None, + skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db)): + if not db.query(Recruitment).filter(Recruitment.id == recruitment_id).first(): + raise HTTPException(404, "Recruitment not found") + qb = db.query(Talent).filter(Talent.recruitment_id == recruitment_id) + if status: qb = qb.filter(Talent.status == status) + return qb.order_by(Talent.updated_at.desc()).offset(skip).limit(limit).all() + +@router.get("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def get_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + return t + +@router.post("/{recruitment_id}/talents", response_model=TalentRead, status_code=201) +def create_talent(recruitment_id: int, data: TalentCreate, db: Session = Depends(get_db)): + recruitment = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not recruitment: raise HTTPException(404, "Recruitment not found") + candidate = db.query(Candidate).filter(Candidate.email == data.email).first() + if not candidate: + candidate = Candidate(email=data.email, real_name=data.real_name); db.add(candidate); db.flush() + app = Application(candidate_id=candidate.id, recruitment_id=recruitment_id, source="manual_debug"); db.add(app); db.flush() + t = Talent(recruitment_id=recruitment_id, email=data.email, real_name=data.real_name) + db.add(t); db.commit(); db.refresh(t); return t + +@router.patch("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def update_talent(recruitment_id: int, talent_id: int, data: TalentUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + for k, v in data.model_dump(exclude_unset=True).items(): setattr(t, k, v) + db.commit(); db.refresh(t); return t + +@router.post("/{recruitment_id}/talents/{talent_id}/transition", response_model=TalentRead) +def transition_talent(recruitment_id: int, talent_id: int, data: TalentTransition, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + if data.status not in STATUS_TRANSITIONS.get(t.status, []): + raise HTTPException(400, f"Cannot transition from {t.status.value} to {data.status.value}") + candidate = db.query(Candidate).filter(Candidate.email == t.email).first() + if candidate: + app = db.query(Application).filter(Application.candidate_id == candidate.id, + Application.recruitment_id == recruitment_id).order_by(Application.created_at.desc()).first() + if app: + app.status = data.status + if data.status != t.status: app.sub_stage = None + if data.sub_stage is not None and data.status in ALLOWED_STATUSES_FOR_SUB_STAGE: + app.sub_stage = data.sub_stage + t.status = app.status; t.sub_stage = app.sub_stage; t.stage_results = app.stage_results + else: + t.status = data.status + db.commit(); db.refresh(t); return t + +@router.patch("/{recruitment_id}/talents/{talent_id}/sub-stage", response_model=TalentRead) +def set_talent_sub_stage(recruitment_id: int, talent_id: int, data: SubStageUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + if t.status not in ALLOWED_STATUSES_FOR_SUB_STAGE: + raise HTTPException(400, f"Cannot set sub_stage for status {t.status.value}") + t.sub_stage = data.sub_stage; db.commit(); db.refresh(t); return t + +@router.delete("/{recruitment_id}/talents/{talent_id}", status_code=204) +def delete_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + db.delete(t); db.commit() diff --git a/examples/human/schemas/__init__.py b/examples/human/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/human/schemas/application.py b/examples/human/schemas/application.py new file mode 100644 index 00000000..947e1843 --- /dev/null +++ b/examples/human/schemas/application.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel, Field +from human.models.talent import TalentStatus + +class ApplicationRead(BaseModel): + id: int; candidate_id: int; recruitment_id: int + status: TalentStatus; sub_stage: str | None; quality: str + stage_results: dict | None; source: str + pooled_at: datetime | None; deactivated_at: datetime | None + created_at: datetime; updated_at: datetime + model_config = {"from_attributes": True} + +class PoolItemRead(BaseModel): + id: int; candidate_id: int; recruitment_id: int + status: TalentStatus; source: str + pooled_at: datetime | None; deactivated_at: datetime | None + candidate_email: str = ""; candidate_name: str = "" + model_config = {"from_attributes": True} + +class UnpoolRequest(BaseModel): + recruitment_id: int = Field(..., ge=1) diff --git a/examples/human/schemas/candidate.py b/examples/human/schemas/candidate.py new file mode 100644 index 00000000..17493585 --- /dev/null +++ b/examples/human/schemas/candidate.py @@ -0,0 +1,6 @@ +from datetime import datetime +from pydantic import BaseModel + +class CandidateRead(BaseModel): + id: int; email: str; real_name: str; phone: str | None; created_at: datetime + model_config = {"from_attributes": True} diff --git a/examples/human/schemas/pending_queue.py b/examples/human/schemas/pending_queue.py new file mode 100644 index 00000000..ec4d25d2 --- /dev/null +++ b/examples/human/schemas/pending_queue.py @@ -0,0 +1,29 @@ +from datetime import datetime +from pydantic import BaseModel + +class ConfirmRequest(BaseModel): + action: str = "confirmed"; status: str = "contacted" + real_name: str = ""; email: str = "" + +class ConfirmResponse(BaseModel): + queue_id: int; action: str; talent_id: int | None = None + +class IgnoreRequest(BaseModel): + action: str = "ignored" + +class QueueItemRead(BaseModel): + queue_id: int; message_id: str; subject: str + sender_name: str | None; sender_email: str | None + suggested_status: str | None; confidence: str + hr_status: str; created_at: str + +class IngestItem(BaseModel): + message_id: str; subject: str + sender_name: str = ""; sender_email: str = "" + suggested_status: str = "contacted"; confidence: str = "low" + +class IngestRequest(BaseModel): + source: str = "example"; items: list[IngestItem] + +class IngestResponse(BaseModel): + batch_id: str | None; queued: int; skipped: int; errors: list[str] diff --git a/examples/human/schemas/recruitment.py b/examples/human/schemas/recruitment.py new file mode 100644 index 00000000..1dec0585 --- /dev/null +++ b/examples/human/schemas/recruitment.py @@ -0,0 +1,9 @@ +from datetime import datetime +from pydantic import BaseModel + +class RecruitmentRead(BaseModel): + id: int; created_at: datetime + model_config = {"from_attributes": True} + +class HeadcountRead(BaseModel): + recruitment_id: int; total_offers: int; accepted: int diff --git a/examples/human/schemas/talent.py b/examples/human/schemas/talent.py new file mode 100644 index 00000000..394f476d --- /dev/null +++ b/examples/human/schemas/talent.py @@ -0,0 +1,24 @@ +from datetime import datetime +from pydantic import BaseModel, Field +from human.models.talent import TalentStatus + +class TalentCreate(BaseModel): + email: str + real_name: str + auto_screening_result: str | None = None + +class TalentRead(BaseModel): + id: int; recruitment_id: int; email: str; real_name: str + status: TalentStatus; sub_stage: str | None; quality: str + stage_results: dict | None; created_at: datetime; updated_at: datetime + model_config = {"from_attributes": True} + +class TalentUpdate(BaseModel): + email: str | None = None; real_name: str | None = None + model_config = {"extra": "forbid"} + +class TalentTransition(BaseModel): + status: TalentStatus; sub_stage: str | None = None + +class SubStageUpdate(BaseModel): + sub_stage: str | None = None diff --git a/examples/human/seed.py b/examples/human/seed.py new file mode 100644 index 00000000..9dfdb3de --- /dev/null +++ b/examples/human/seed.py @@ -0,0 +1,107 @@ +from datetime import datetime, timedelta +from hashlib import md5 +from sqlalchemy import update +from sqlalchemy.orm import Session +from human.models.application import Application +from human.models.candidate import Candidate +from human.models.pending_queue import PendingQueueItem +from human.models.recruitment import Recruitment +from human.models.talent import Talent, TalentStatus + +SEED_TRANSITIONS = { + s: [] for s in ["new", "contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer", "closed"] +} +SEED_TRANSITIONS["contacted"] = ["contacted"] +SEED_TRANSITIONS["exam_sent"] = ["contacted", "exam_sent"] +SEED_TRANSITIONS["exam_received"] = ["contacted", "exam_sent", "exam_received"] +SEED_TRANSITIONS["evaluating"] = ["contacted", "exam_sent", "exam_received", "evaluating"] +SEED_TRANSITIONS["interview"] = ["contacted", "exam_sent", "exam_received", "evaluating", "interview"] +SEED_TRANSITIONS["offer"] = ["contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer"] +SEED_TRANSITIONS["closed"] = ["closed"] + +DEMO_TALENTS = [ + ("new", f"张{cn}", f"zhang{i}@demo.local", None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("contacted", f"李{cn}", f"li{i}@demo.local", None if i > 3 else "resume_passed") for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("exam_sent", f"王{cn}", f"wang{i}@demo.local", "taking" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("exam_received", f"赵{cn}", f"zhao{i}@demo.local", None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("evaluating", f"孙{cn}", f"sun{i}@demo.local", "exam_passed" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("interview", f"周{cn}", f"zhou{i}@demo.local", "interview_passed" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("offer", f"吴{cn}", f"wu{i}@demo.local", "accepted" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("closed", f"郑{cn}", f"zheng{i}@demo.local", None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + +QUALITY_MAP = {"李二": "excellent", "李三": "excellent", "李四": "excellent", + "孙二": "excellent", "孙三": "excellent", "周子": "excellent", + "吴二": "excellent", "吴三": "excellent", "张五": "excellent"} + +def build_transition_chain(target: str) -> list[str]: + return SEED_TRANSITIONS[target] + +def seed_data(db: Session) -> None: + import human.models # noqa: F401 + r = Recruitment() + db.add(r); db.flush() + + for target_status, name, email, sub_stage in DEMO_TALENTS: + t = Talent(recruitment_id=r.id, email=email, real_name=name) + db.add(t); db.flush() + for s in build_transition_chain(target_status): + t.status = TalentStatus(s); db.flush() + t.sub_stage = sub_stage + t.quality = QUALITY_MAP.get(name, "normal") + stage_map = {"exam_sent": {"contacted": "pass"}, "exam_received": {"contacted": "pass"}, + "evaluating": {"contacted": "pass"}, "interview": {"contacted": "pass", "evaluating": "pass"}, + "offer": {"contacted": "pass", "evaluating": "pass", "interview": "pass"}} + t.stage_results = stage_map.get(target_status); db.flush() + db.commit() + + status_age = {"new": 0, "contacted": 2, "exam_sent": 5, "exam_received": 8, + "evaluating": 12, "interview": 15, "offer": 20, "closed": 25} + for target_status, name, email, _ in DEMO_TALENTS: + days = status_age[target_status] + if days > 0: + db.execute(update(Talent).where(Talent.email == email).values(updated_at=datetime.utcnow() - timedelta(days=days))) + db.commit() + + email_to_candidate = {} + for target_status, name, email, _ in DEMO_TALENTS: + if email not in email_to_candidate: + c = Candidate(email=email, real_name=name); db.add(c); db.flush() + email_to_candidate[email] = c + + for target_status, name, email, sub_stage in DEMO_TALENTS: + talent = db.query(Talent).filter(Talent.email == email).first() + if talent: + a = Application(candidate_id=email_to_candidate[email].id, recruitment_id=r.id, + status=talent.status, sub_stage=talent.sub_stage, quality=talent.quality, + stage_results=talent.stage_results, source="manual_seed") + db.add(a); db.flush() + + zhang3 = email_to_candidate.get("zhang3@demo.local") + if zhang3: + db.add(Application(candidate_id=zhang3.id, recruitment_id=r.id, status=TalentStatus.NEW, + source="manual_seed", pooled_at=datetime.utcnow())) + wang5 = email_to_candidate.get("wang5@demo.local") + if wang5: + db.add(Application(candidate_id=wang5.id, recruitment_id=r.id, status=TalentStatus.EXAM_SENT, source="manual_seed")) + db.commit() + + from human.classifier import classify + from human.demo import get_demo_emails + for email in get_demo_emails(): + result = classify(email.subject, email.sender_name, email.sender_email) + qi = PendingQueueItem( + message_id=md5(email.subject.encode()).hexdigest()[:16], + subject=email.subject, sender_name=email.sender_name, + sender_email=email.sender_email, + suggested_status=result.suggested_status, confidence=result.confidence, + ) + db.add(qi) + db.commit() diff --git a/examples/human/services/__init__.py b/examples/human/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/human/services/headcount.py b/examples/human/services/headcount.py new file mode 100644 index 00000000..28fb011a --- /dev/null +++ b/examples/human/services/headcount.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session +from human.models.talent import TalentStatus +from human.models.application import Application + +def get_headcount(db: Session, recruitment_id: int) -> dict: + total = db.query(Application).filter( + Application.recruitment_id == recruitment_id, + Application.status == TalentStatus.OFFER, + Application.pooled_at.is_(None), + ).count() + accepted = db.query(Application).filter( + Application.recruitment_id == recruitment_id, + Application.status == TalentStatus.OFFER, + Application.sub_stage == "accepted", + ).count() + return {"recruitment_id": recruitment_id, "total_offers": total, "accepted": accepted} diff --git a/examples/human/services/pipeline.py b/examples/human/services/pipeline.py new file mode 100644 index 00000000..412c032b --- /dev/null +++ b/examples/human/services/pipeline.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Session +from human.models.talent import Talent, TalentStatus + +def get_pipeline(db: Session) -> dict: + talents = db.query(Talent).filter(Talent.status != TalentStatus.CLOSED).all() + stages = {s.value: [] for s in TalentStatus} + for t in talents: + stages[t.status.value].append(_talent_to_card(t)) + summary = {"total": len(talents), "by_stage": {}} + for s in TalentStatus: + count = len(stages[s.value]) + if count > 0: + summary["by_stage"][s.value] = count + return {"stages": stages, "summary": summary} + +def _talent_to_card(t: Talent) -> dict: + return { + "id": t.id, "email": t.email, "real_name": t.real_name, + "recruitment_id": t.recruitment_id, "status": t.status.value, + "sub_stage": t.sub_stage, "quality": t.quality, + "stage_results": t.stage_results, + "created_at": t.created_at.isoformat() if t.created_at else None, + "updated_at": t.updated_at.isoformat() if t.updated_at else None, + } diff --git a/examples/human/services/pool.py b/examples/human/services/pool.py new file mode 100644 index 00000000..0e706da8 --- /dev/null +++ b/examples/human/services/pool.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import Session, joinedload +from human.models.application import Application +from human.models.talent import TalentStatus + +def pool_application(db: Session, application_id: int) -> Application: + app = db.query(Application).filter(Application.id == application_id).first() + if not app: + raise ValueError("Application not found") + now = datetime.now(timezone.utc) + app.pooled_at = now + app.deactivated_at = now + app.status = TalentStatus.CLOSED + db.commit() + db.refresh(app) + return app + +def unpool_application(db: Session, application_id: int, recruitment_id: int) -> Application: + app = db.query(Application).filter(Application.id == application_id).first() + if not app: + raise ValueError("Application not found") + if app.pooled_at is None: + raise ValueError("Application is not pooled") + new_app = Application( + candidate_id=app.candidate_id, recruitment_id=recruitment_id, + status=TalentStatus.NEW, source="pool", + ) + db.add(new_app) + db.commit() + db.refresh(new_app) + return new_app + +def get_pooled_applications(db: Session) -> list[Application]: + return (db.query(Application) + .options(joinedload(Application.candidate)) + .filter(Application.pooled_at.isnot(None)) + .order_by(Application.pooled_at.desc()).all()) diff --git a/examples/human/static/index.html b/examples/human/static/index.html new file mode 100644 index 00000000..cc51f0ae --- /dev/null +++ b/examples/human/static/index.html @@ -0,0 +1,1182 @@ + + + + + +招聘管道看板 + 飞书确认队列 + + + + +
+ +

招聘管道看板

+
+ + + + + +
+
+ +
+ +
+ 加载失败 + +
+
+ 后端服务连接断开 + +
+ +
+
+
+ 飞书邮件确认队列 + 0 +
+
+
+
+ +
+
+
+ +
+
+

人才库

+
+
+
+
+
+ +
+

AI 配置

+
+

加载中...

+
+
+ +
+
+

候选人材料

+ × +
+
+
点击候选人查看详情
+
+
+
+ +
+
+
+ 预览 + × +
+
+
+
+
加载中...
+
+ + +
+
+
+ +
+
+
加载中...
+
+ +
+ + + + diff --git a/examples/prototype/panorama.html b/examples/prototype/panorama.html new file mode 100644 index 00000000..1fda8423 --- /dev/null +++ b/examples/prototype/panorama.html @@ -0,0 +1,515 @@ + + + + + + 量潮 · 今日 + + + +

量潮科技

+
2026年5月2日 · 星期六
+ + +
+

业务线

+
+ +
+

量潮数据 主营

+ +
+
+ 陈小明本周内回复 +
+
华为数据清洗 · 接不接?
+
+ 回头客 + ¥12,000,10周。产能刚好够,但接了教育类要等一个月。 +
+
小明倾向:接,维持老客户
+
+ + + +
+
+ +
+
+ 李四维下周一前 +
+
牛津项目 · 新增分析维度
+
+ 合同外需求。加则多2周,不加可能影响海外口碑。 +
+
四维建议:加,牛津是桥头堡
+
+ + +
+
+
+ + +
+

量潮课堂 主营

+ +
+
+ 王老师今日需定 +
+
杭电Python实训 · 已超期2周
+
+ 客户在催。加人赶工还是谈延期? +
+
王老师建议:谈延期
+
+ + +
+
+
+ + +
+

量潮云 孵化中

+
+ 暂无待决策事项
市场调研进行中 +
+
+
+
+ + +
+

职能线

+
+
+

人力资源

+
+ 团队8人 +
+
+ 出勤全员 +
+
+ 待审批0 +
+
无异常
+
+
+

财务管理

+
+ 本月回款¥84k/120k +
+
+ 现金流健康 +
+
无预警
+
+
+

组织管理

+
+ 决策委托率42% +
+
↓5% 比上月
+ 连续2月下降 +
+ 标准化率60%↑8% +
+
+ 去中心化度40%↑5% +
+
+
+

战略管理

+
+ 季度OKR推进中 +
+
+ 量潮云报告下周出 +
+
无阻塞
+
+
+

新媒体

+
+ 公众号按时 +
+
+ 知乎3篇/周 +
+
稳定
+
+
+ +
+ +
+ 其余5位成员无需你介入 · 今日无待审批报销 · 本周全员周报已提交 +
+ + + + diff --git a/examples/prototype/qtconsult.html b/examples/prototype/qtconsult.html new file mode 100644 index 00000000..adb17a48 --- /dev/null +++ b/examples/prototype/qtconsult.html @@ -0,0 +1,1793 @@ + + + + + + 量潮咨询 · 项目详情 + + + + +
+ + +
+
+ ← 量潮咨询 +

某制造企业数字化项目

+
+
+ 最后更新:5月14日 15:30 + + 方案期 +
+
+ + +
+
+ 已确认发现 + 3 +
+
+ 高风险 + 2 +
+
+ 阻碍项 + 1 +
+ +
+ + +
+ +
+

+ 信息看板 + 客户是什么情况 +

+ +
+ 制造业 · 电子零部件 + 500人 + 数字化成熟度 L2 +
+ +
发现清单
+
    + + +
    沟通记录
    +
    +
    + + +
    +

    + 策略看板 + 我们怎么应对 + +

    + +
    +

    战略诉求

    +

    客户表述:实现生产数据可视化,提升管理效率

    +

    + 判断:真实诉求可能是产能利用率不透明,管理层无法掌握真实生产进度。数据可视化只是手段,不是目的。 +

    +
    + +
    +

    切入策略

    +
      +
    • + 第一步:ERP数据打通试点(1个车间),快速出成果建立信任 +
    • +
    • + 第二步:中层动员工作坊,让中层参与方案设计,减少实施阻力 +
    • +
    • 第三步:逐步推广至全厂,同步评估IT团队扩容方案
    • +
    +
    + ⚠ 风险:IT人力不足是硬约束,需在第一步中评估实际运维需求 +
    +
    + +
    +

    决策链路

    +
    +
    + +
    + 策略修正记录 +
    +
    + +
    +
    + + + + + + + diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 00000000..68c717d3 --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "qtadmin-example" +version = "0.1.0" +description = "qtadmin HR demo example" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.136.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "pydantic>=2.0.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["human"] diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index 3c93ce31..00000000 --- a/lib/main.dart +++ /dev/null @@ -1,30 +0,0 @@ -/// APP入口 - -import 'package:flutter/material.dart'; - - -/// APP入口函数 -void main() { - runApp(const QtAdminStudio()); -} - -/// APP类 -class QtAdminStudio extends StatelessWidget { - const QtAdminStudio({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: '量潮管理后台', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - initialRoute: '/', - routes: { - /// 项目列表页面 - '/': (context) => const Text('首页'), - - } - ); - } -} diff --git a/lib/models/transaction.dart b/lib/models/transaction.dart deleted file mode 100644 index 3b1e50bf..00000000 --- a/lib/models/transaction.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'transaction.freezed.dart'; - -@freezed -abstract class Transaction with _$Transaction { - const factory Transaction({ - required String id, - required double amount, - }) = _Transaction; -} \ No newline at end of file diff --git a/lib/models/transaction.freezed.dart b/lib/models/transaction.freezed.dart deleted file mode 100644 index d05e4640..00000000 --- a/lib/models/transaction.freezed.dart +++ /dev/null @@ -1,158 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'transaction.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; - -/// @nodoc -mixin _$Transaction { - String get id; - double get amount; - - /// Create a copy of Transaction - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @pragma('vm:prefer-inline') - $TransactionCopyWith get copyWith => - _$TransactionCopyWithImpl(this as Transaction, _$identity); - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is Transaction && - (identical(other.id, id) || other.id == id) && - (identical(other.amount, amount) || other.amount == amount)); - } - - @override - int get hashCode => Object.hash(runtimeType, id, amount); - - @override - String toString() { - return 'Transaction(id: $id, amount: $amount)'; - } -} - -/// @nodoc -abstract mixin class $TransactionCopyWith<$Res> { - factory $TransactionCopyWith( - Transaction value, $Res Function(Transaction) _then) = - _$TransactionCopyWithImpl; - @useResult - $Res call({String id, double amount}); -} - -/// @nodoc -class _$TransactionCopyWithImpl<$Res> implements $TransactionCopyWith<$Res> { - _$TransactionCopyWithImpl(this._self, this._then); - - final Transaction _self; - final $Res Function(Transaction) _then; - - /// Create a copy of Transaction - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? amount = null, - }) { - return _then(_self.copyWith( - id: null == id - ? _self.id - : id // ignore: cast_nullable_to_non_nullable - as String, - amount: null == amount - ? _self.amount - : amount // ignore: cast_nullable_to_non_nullable - as double, - )); - } -} - -/// @nodoc - -class _Transaction implements Transaction { - const _Transaction({required this.id, required this.amount}); - - @override - final String id; - @override - final double amount; - - /// Create a copy of Transaction - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - @pragma('vm:prefer-inline') - _$TransactionCopyWith<_Transaction> get copyWith => - __$TransactionCopyWithImpl<_Transaction>(this, _$identity); - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _Transaction && - (identical(other.id, id) || other.id == id) && - (identical(other.amount, amount) || other.amount == amount)); - } - - @override - int get hashCode => Object.hash(runtimeType, id, amount); - - @override - String toString() { - return 'Transaction(id: $id, amount: $amount)'; - } -} - -/// @nodoc -abstract mixin class _$TransactionCopyWith<$Res> - implements $TransactionCopyWith<$Res> { - factory _$TransactionCopyWith( - _Transaction value, $Res Function(_Transaction) _then) = - __$TransactionCopyWithImpl; - @override - @useResult - $Res call({String id, double amount}); -} - -/// @nodoc -class __$TransactionCopyWithImpl<$Res> implements _$TransactionCopyWith<$Res> { - __$TransactionCopyWithImpl(this._self, this._then); - - final _Transaction _self; - final $Res Function(_Transaction) _then; - - /// Create a copy of Transaction - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $Res call({ - Object? id = null, - Object? amount = null, - }) { - return _then(_Transaction( - id: null == id - ? _self.id - : id // ignore: cast_nullable_to_non_nullable - as String, - amount: null == amount - ? _self.amount - : amount // ignore: cast_nullable_to_non_nullable - as double, - )); - } -} - -// dart format on diff --git a/lib/screens/transaction_form_screen.dart b/lib/screens/transaction_form_screen.dart deleted file mode 100644 index 7d6bbf72..00000000 --- a/lib/screens/transaction_form_screen.dart +++ /dev/null @@ -1,55 +0,0 @@ -/// 交易编辑表单页面 -/// -/// 用于创建或编辑交易记录 -/// -/// 该页面包含一个表单,用户可以输入交易的相关信息 -/// 包括交易类型、金额、日期等 -/// 该页面还提供了保存和取消按钮 - -import 'package:flutter/material.dart'; - -class TransactionFormScreen extends StatelessWidget { - const TransactionFormScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('交易编辑'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - child: Column( - children: [ - TextFormField( - decoration: const InputDecoration(labelText: '交易类型'), - ), - TextFormField( - decoration: const InputDecoration(labelText: '金额'), - keyboardType: TextInputType.number, - ), - TextFormField( - decoration: const InputDecoration(labelText: '日期'), - keyboardType: TextInputType.datetime, - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () { - // 保存逻辑 - }, - child: const Text('保存'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('取消'), - ), - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/screens/transaction_list_screen.dart b/lib/screens/transaction_list_screen.dart deleted file mode 100644 index b32f349c..00000000 --- a/lib/screens/transaction_list_screen.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/transaction.dart'; - -class TransactionListScreen extends StatefulWidget { - final bool isLoading; - final List? transactions; - - const TransactionListScreen({ - super.key, - this.isLoading = false, - this.transactions, - }); - - @override - State createState() => _TransactionListScreenState(); -} - -class _TransactionListScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Transactions'), - ), - body: widget.isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - itemCount: widget.transactions?.length ?? 0, - itemBuilder: (context, index) { - final transaction = widget.transactions![index]; - return ListTile( - title: Text('\$${transaction.amount.toStringAsFixed(2)}'), - subtitle: Text('ID: ${transaction.id}'), - ); - }, - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/navigation_widget.dart b/lib/widgets/navigation_widget.dart deleted file mode 100644 index 999ff53e..00000000 --- a/lib/widgets/navigation_widget.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; - -class NavigationWidget extends StatelessWidget { - const NavigationWidget({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: ListView( - children: [ - ListTile( - title: Text('Home'), - onTap: () => Navigator.pushNamed(context, '/home'), - ), - ListTile( - title: Text('Settings'), - onTap: () => Navigator.pushNamed(context, '/settings'), - ), - ListTile( - title: Text('Profile'), - onTap: () => Navigator.pushNamed(context, '/profile'), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/manifests/README.md b/manifests/README.md new file mode 100644 index 00000000..5dd9bf35 --- /dev/null +++ b/manifests/README.md @@ -0,0 +1,14 @@ +# manifests + +部署清单。 + +## terraform + +基础设施即代码。用于管理云资源。 + +```bash +cd terraform +terraform init +terraform plan +terraform apply +``` diff --git a/manifests/qtadmin-mail-sender.service b/manifests/qtadmin-mail-sender.service new file mode 100644 index 00000000..2e03df5e --- /dev/null +++ b/manifests/qtadmin-mail-sender.service @@ -0,0 +1,30 @@ +[Unit] +Description=QtAdmin Mail Sender — outbox polling daemon +Documentation=https://github.com/quanttide/qtadmin +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=linli +WorkingDirectory=/home/linli/桌面/qt-hr/qtadmin/src/cli +ExecStart=/home/linli/桌面/qt-hr/qtadmin/src/cli/.venv/bin/python -m app.human.mail_sender_loop +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=300 +StartLimitBurst=10 + +# Environment +Environment=QTADMIN_SERVER_URL=http://localhost:8080 +Environment=QTADMIN_MAILBOX=tc@huaxiadiyishenyi.online +Environment=HOME=/home/linli + +# PATH must include ~/.npm-global/bin for lark-cli +Environment=PATH=/home/linli/.npm-global/bin:/usr/local/bin:/usr/bin:/bin + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/manifests/qtadmin-provider.service b/manifests/qtadmin-provider.service new file mode 100644 index 00000000..bdbec4d5 --- /dev/null +++ b/manifests/qtadmin-provider.service @@ -0,0 +1,22 @@ +[Unit] +Description=QtAdmin Provider — HR recruitment pipeline API +Documentation=https://github.com/quanttide/qtadmin +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=linli +WorkingDirectory=/home/linli/桌面/qt-hr/qtadmin/src/provider +ExecStart=/home/linli/桌面/qt-hr/qtadmin/src/provider/.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8080 +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=300 +StartLimitBurst=10 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/manifests/terraform/.terraform.lock.hcl b/manifests/terraform/.terraform.lock.hcl new file mode 100644 index 00000000..581345e6 --- /dev/null +++ b/manifests/terraform/.terraform.lock.hcl @@ -0,0 +1,21 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/aliyun/alicloud" { + version = "1.277.0" + constraints = "~> 1.212" + hashes = [ + "h1:aacgiVxxIPvblAJTzLnLDVoqlLX6o1t6EIhV1jIcX6I=", + "zh:0399b8e2fa58d91bbe79d35deb433b38ee386c15fedadd61bf9b0d2b3c214c66", + "zh:17d3ae75df42a04ac312e23bdd01c549b1a1365b698e8ed1c114bc1aa9d019cb", + "zh:1a64d627f1275418a4040a90e0bf32d48a283d7ce605e2a9ccc213b708487000", + "zh:2fb779041fc1e4e3080c38dd4f7734ff907fab227571699b8f2071d914591873", + "zh:3c5b6a59503ba74222924b30744770c8c36261e0a811a9ddf8e5add40f94ed1c", + "zh:65c7502990ce8511e667660094bbf28adf2cfef8fb3efee68e5fd26b9725b7e3", + "zh:8d4a77815fcd97d63e88c32047ac4defbfb07e64e76fff5a33ae0932bf4550f3", + "zh:cb16073e07c1de02f39b575d7a5d2386bcd4fe002bd8f0479de71fefffe51d65", + "zh:d27ba53d06a3ce9581994bc2134db33968c85a70557d0301c90510ac2b50fea3", + "zh:ea181c94d8205bdb2a418b52c13e2fd030fb066d28f18e4866e877524bf6290b", + "zh:f16eb293123d988e88ca8cb578920b26b5d33616a82343fbbd7b12719025f8b1", + ] +} diff --git a/manifests/terraform/main.tf b/manifests/terraform/main.tf new file mode 100644 index 00000000..aee8193c --- /dev/null +++ b/manifests/terraform/main.tf @@ -0,0 +1,42 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + alicloud = { + source = "aliyun/alicloud" + version = "~> 1.212" + } + } +} + +provider "alicloud" { + region = var.region +} + +resource "alicloud_oss_bucket" "website" { + bucket = var.bucket_name + + versioning { + status = "Enabled" + } + + website { + index_document = var.index_document + error_document = var.error_document + } + + lifecycle { + prevent_destroy = false + } +} + +resource "alicloud_alidns_record" "admin_cname" { + domain_name = "quanttide.com" + type = "CNAME" + rr = "admin" + value = "${alicloud_oss_bucket.website.bucket}.${alicloud_oss_bucket.website.extranet_endpoint}" + ttl = 600 + status = "ENABLE" + + depends_on = [alicloud_oss_bucket.website] +} diff --git a/manifests/terraform/outputs.tf b/manifests/terraform/outputs.tf new file mode 100644 index 00000000..705f48a4 --- /dev/null +++ b/manifests/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "bucket_name" { + value = alicloud_oss_bucket.website.bucket + description = "The name of the OSS bucket" +} + +output "bucket_endpoint" { + value = alicloud_oss_bucket.website.extranet_endpoint + description = "The public endpoint of the OSS bucket, used as the CNAME target" +} + +output "website_url" { + value = "https://${var.domain_name}" + description = "The URL of the static website, available after the custom domain is bound" +} + +output "dns_record_id" { + value = alicloud_alidns_record.admin_cname.id + description = "The DNS record ID" +} diff --git a/manifests/terraform/variables.tf b/manifests/terraform/variables.tf new file mode 100644 index 00000000..855dc4d8 --- /dev/null +++ b/manifests/terraform/variables.tf @@ -0,0 +1,29 @@ +variable "region" { + description = "Aliyun OSS region" + type = string + default = "cn-hangzhou" +} + +variable "bucket_name" { + description = "OSS bucket name" + type = string + default = "qtadmin-studio" +} + +variable "domain_name" { + description = "Custom domain for the website" + type = string + default = "admin.quanttide.com" +} + +variable "index_document" { + description = "Index document for static website hosting" + type = string + default = "index.html" +} + +variable "error_document" { + description = "Error document for static website hosting" + type = string + default = "index.html" +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..9da79126 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = src/provider diff --git a/scripts/install-services.sh b/scripts/install-services.sh new file mode 100755 index 00000000..25944f0e --- /dev/null +++ b/scripts/install-services.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Install qtadmin systemd services +# Run as root: sudo bash scripts/install-services.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFESTS_DIR="$SCRIPT_DIR/../manifests" + +if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: must run as root (sudo)" + exit 1 +fi + +SERVICES=( + "qtadmin-provider.service" + "qtadmin-mail-sender.service" +) + +for svc in "${SERVICES[@]}"; do + src="$MANIFESTS_DIR/$svc" + if [ ! -f "$src" ]; then + echo "WARNING: $src not found, skipping" + continue + fi + cp "$src" "/etc/systemd/system/$svc" + echo "Installed $svc" +done + +systemctl daemon-reload + +for svc in "${SERVICES[@]}"; do + systemctl enable "$svc" + systemctl restart "$svc" || echo "WARNING: $svc failed to start (may need user/config setup)" +done + +echo "" +echo "=== Status ===" +for svc in "${SERVICES[@]}"; do + systemctl status "$svc" --no-pager 2>&1 | head -5 + echo "" +done + +echo "" +echo "Commands:" +echo " systemctl status qtadmin-provider # check provider status" +echo " journalctl -u qtadmin-provider -f # tail provider logs" +echo " systemctl status qtadmin-mail-sender # check mail sender status" +echo " journalctl -u qtadmin-mail-sender -f # tail mail sender logs" diff --git a/scripts/qtadmin b/scripts/qtadmin new file mode 100755 index 00000000..c8813c89 --- /dev/null +++ b/scripts/qtadmin @@ -0,0 +1,2 @@ +#!/bin/bash +exec /home/linli/桌面/qt-hr/qtadmin/src/cli/.venv/bin/qtadmin "$@" diff --git a/scripts/record-studio-linux.sh b/scripts/record-studio-linux.sh new file mode 100644 index 00000000..ba84e310 --- /dev/null +++ b/scripts/record-studio-linux.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR/.." +STUDIO_BIN="$PROJECT_DIR/src/studio/build/linux/x64/release/bundle/qtadmin-studio" +VIDEO_OUT="$PROJECT_DIR/assets/videos/studio.mp4" + +WINFO_FILE="/tmp/studio_win.txt" + +cleanup() { + echo "" + echo "Stopping..." + pkill -f "qtadmin-studio" 2>/dev/null || true + pkill -f "ffmpeg.*x11grab" 2>/dev/null || true + xdotool mousemove 0 0 2>/dev/null || true + rm -f "$WINFO_FILE" +} +trap cleanup EXIT + +cleanup +sleep 1 + +echo "Starting studio..." +"$STUDIO_BIN" & +sleep 4 + +# Find content window +WID=$(xdotool search --name "量潮管理后台" 2>/dev/null | tail -1) +if [ -z "$WID" ]; then + echo "ERROR: Cannot find content window" >&2 + exit 1 +fi +echo "Content Window ID: $WID" +xdotool getwindowgeometry "$WID" +echo "Window name: $(xdotool getwindowname "$WID")" + +# Save geometry +eval "$(xdotool getwindowgeometry --shell "$WID")" +echo "CONTENT_X=$X CONTENT_Y=$Y CONTENT_W=$WIDTH CONTENT_H=$HEIGHT" +echo "$X $Y $WIDTH $HEIGHT" > "$WINFO_FILE" + +# Activate window +xdotool windowactivate --sync "$WID" +xdotool windowraise "$WID" +sleep 1 + +# Record only the window area (pad to even dimensions for libx264) +echo "Recording window area to $VIDEO_OUT..." +ffmpeg -y -f x11grab -video_size "${WIDTH}x${HEIGHT}" -i ":0.0+${X},${Y}" \ + -framerate 30 -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2" \ + -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p "$VIDEO_OUT" & +FFMPEG_PID=$! +sleep 2 + +# Re-activate +xdotool windowactivate --sync "$WID" +xdotool windowraise "$WID" +sleep 0.5 + +# === Precise layout (window-relative from source code) === +# SizedBox(4) + WorkspaceSwitcher(h=60) -> center at y=34 +# Divider: Padding(v=4) + Divider(h=1) + Padding(v=4) -> h=9 total +# NavIcon: h=64 each, centers at y=105, 169, 233, 297, 361, 425, 489 +SIDEBAR_CX=36 +TS_Y=34 +NAV1_Y=105 # 全景图 +NAV2_Y=169 # 思考 +NAV3_Y=233 # 写作 +NAV4_Y=297 # 量潮数据 +NAV5_Y=361 # 量潮课堂 +NAV6_Y=425 # 量潮咨询 +NAV7_Y=489 # 量潮云 + +click_win() { + xdotool windowactivate --sync "$WID" 2>/dev/null || true + xdotool mousemove --window "$WID" "$1" "$2" click 1 + sleep "$3" +} + +# ===== Interactions ===== + +# --- 量潮创始人 --- +click_win "$SIDEBAR_CX" "$NAV1_Y" 2 # 全景图 +click_win "$SIDEBAR_CX" "$NAV2_Y" 2 # 思考 +click_win "$SIDEBAR_CX" "$NAV3_Y" 2 # 写作(placeholder) +click_win "$SIDEBAR_CX" "$NAV1_Y" 2 # 回全景图 + +# 切换Workspace工作空间: PopupMenu offset(0,48), trigger bottom at 64, menu starts at 112 +click_win "$SIDEBAR_CX" "$TS_Y" 1 # 点击Workspace工作空间切换 +click_win 80 184 2 # 菜单项2: 量潮科技(约112-160+48) + +# --- 量潮科技 --- +click_win "$SIDEBAR_CX" "$NAV1_Y" 2 # 全景图 +click_win "$SIDEBAR_CX" "$NAV4_Y" 2 # 量潮数据 +click_win "$SIDEBAR_CX" "$NAV5_Y" 2 # 量潮课堂 +click_win "$SIDEBAR_CX" "$NAV1_Y" 2 # 回全景图 + +# 点击内容区决策卡片 +for i in 1 2 3; do + click_win 500 $((300 + i * 80)) 1.5 +done + +click_win "$SIDEBAR_CX" "$NAV2_Y" 2 # 思考 +click_win "$SIDEBAR_CX" "$NAV1_Y" 2 # 回全景图 + +# 鼠标移开 +xdotool windowactivate --sync "$WID" +xdotool mousemove --window "$WID" 1200 700 +sleep 1 + +# Stop +echo "Stopping recording..." +kill "$FFMPEG_PID" 2>/dev/null || true +sleep 2 + +echo "Done! Video saved to $VIDEO_OUT" diff --git a/scripts/run-studio-linux.sh b/scripts/run-studio-linux.sh new file mode 100755 index 00000000..316259ff --- /dev/null +++ b/scripts/run-studio-linux.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR/.." +STUDIO_DIR="$PROJECT_DIR/src/studio" + +echo "Building Linux bundle..." +cd "$STUDIO_DIR" +flutter build linux + +echo "" +echo "Running..." +exec ./build/linux/x64/release/bundle/qtadmin-studio diff --git a/scripts/start-all.sh b/scripts/start-all.sh new file mode 100755 index 00000000..86ba513a --- /dev/null +++ b/scripts/start-all.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Start all qtadmin services for development +# Provider API → :8080 | Demo frontend → :8000 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR/.." + +cleanup() { + echo "" + echo "Stopping all services..." + pkill -f "uvicorn app.__main__:app" 2>/dev/null || true + pkill -f "examples/human/app.py" 2>/dev/null || true + echo "All services stopped." +} +trap cleanup EXIT + +# Start provider +echo "Starting Provider API on :8080..." +cd "$PROJECT_DIR/src/provider" +.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8080 & +PROVIDER_PID=$! +echo " Provider PID: $PROVIDER_PID" + +# Wait for provider to be ready +sleep 2 + +# Start demo +echo "Starting Demo frontend on :8000..." +cd "$PROJECT_DIR" +python examples/human/app.py & +DEMO_PID=$! +echo " Demo PID: $DEMO_PID" + +echo "" +echo "=== All services started ===" +echo " Provider API: http://localhost:8080" +echo " Demo frontend: http://localhost:8000" +echo " Health check: http://localhost:8080/health" +echo "" +echo "Press Ctrl+C to stop all services." + +# Wait for any process to exit +wait diff --git a/scripts/upload_oss.py b/scripts/upload_oss.py new file mode 100644 index 00000000..7fc08b8e --- /dev/null +++ b/scripts/upload_oss.py @@ -0,0 +1,25 @@ +import os +import sys + +import oss2 + + +access_key_id = os.environ.get("ALIYUN_ACCESS_KEY_ID") +access_key_secret = os.environ.get("ALIYUN_ACCESS_KEY_SECRET") + +if not access_key_id or not access_key_secret: + print("Error: ALIYUN_ACCESS_KEY_ID or ALIYUN_ACCESS_KEY_SECRET not set") + sys.exit(1) + +auth = oss2.Auth(access_key_id, access_key_secret) +bucket = oss2.Bucket(auth, "oss-cn-hangzhou.aliyuncs.com", "qtadmin-studio") + +local_dir = "build/web" +for root, dirs, files in os.walk(local_dir): + for file in files: + local_path = os.path.join(root, file) + oss_path = os.path.relpath(local_path, local_dir).replace(os.sep, "/") + bucket.put_object_from_file(oss_path, local_path) + print(f"Uploaded: {oss_path}") + +print("Done!") diff --git a/src/cli/CHANGELOG.md b/src/cli/CHANGELOG.md new file mode 100644 index 00000000..1af75021 --- /dev/null +++ b/src/cli/CHANGELOG.md @@ -0,0 +1,145 @@ +# CHANGELOG + +## [0.0.2] - 2026-06-16 + +### Added + +- `human` 命令组:招聘邮箱集成与 AI 智能分类 + - `human list` — 列出飞书邮箱收件箱邮件 + - `human classify` — AI 分类单封邮件 + - `human ingest` — 推送邮件到待确认队列 + - `human config` — 配置管理(Provider 地址、lark-cli 路径) + - `human send-loop` — 发件箱轮询发送守护进程 +- `app.human.lark_client` — 飞书 lark-cli 子进程封装 +- `app.human.api_client` — Provider API HTTP 客户端 +- `app.human.mail_sender` — 邮件发送逻辑 + 轮询循环 +- `app.human.classifier` — AI 分类结果预览 +- `app.human.config` — 本地配置管理 + +### Fixed + +- `app.human.lark_client._run` 新增 `timeout=30` 和 `check_returncode()` + +## [0.0.1] - 2026-04-07 + +首个正式版本,提供数字资产管理工具集。 + +### Added +- CLI 基础框架:使用 typer 构建命令行工具,支持 `--help` 和 `--version` +- `asset backup` 命令:日志归档功能,支持递归扫描和嵌套目录结构 +- `asset audit` 命令:资产审计功能 + - AGENTS.md 完整性检查(行数阈值、自我更新说明要求) + - 版本发布规范一致性检查 + - 提交规范检查 +- 动态获取子模块路径:从 `.gitmodules` 自动读取 +- 测试套件:集成测试和单元测试 +- 完整的用户文档和开发文档 + +### Changed +- 重构包结构:将 `qtadmin_cli` 重命名为 `app` +- 重构命令组:将 `meta` 重命名为 `asset`(数字资产职能) +- 版本号单一数据源:仅在 `pyproject.toml` 维护,代码动态获取 + +### Removed +- `asset refresh` 命令(功能已迁移至其他工具) + +### Fixed +- CLI 入口点配置 +- 构建配置支持 `uv pip install` + +## [0.0.1-beta.5] - 2026-04-04 + +### Removed +- 移除 `asset refresh` 命令 + +## [0.0.1-beta.4] - 2026-04-04 + +### Fixed +- `asset refresh`: 修复无法识别云端更新的问题,改为比较父仓库记录的子模块 commit 与远程 HEAD +- `asset refresh`: 同步逻辑改用 `git submodule update --remote` + +## [0.0.1-beta.3] - 2026-04-04 + +### Fixed +- `asset refresh`: 修复硬编码 `origin/main` 导致无法识别非 main 分支子模块的更新 +- `asset refresh`: 同步逻辑改为动态获取子模块对应远程分支 + +## [0.0.1-beta.2] - 2026-04-04 + +### Fixed +- `asset backup`: 递归扫描 journal 目录,支持任意嵌套层级 +- `asset backup`: 归档后保持原始嵌套目录结构 +- `asset audit`: 统一提交规范正则表达式 +- `asset audit`: 子模块超时返回失败状态 +- `asset audit`: AGENTS.md 行数阈值调整为 50 行 + +### Added +- `asset audit`: 版本发布规范一致性检查 + +### Documentation +- 添加 `docs/dev/asset_audit.md` 开发文档 +- 添加 `docs/dev/asset_backup.md` 开发文档 +- 更新 `docs/user/asset_backup.md` 用户文档 +- 更新 CONTRIBUTING.md 发布流程 + +### Tests +- 更新单元测试支持递归扫描和嵌套目录 +- 更新集成测试验证嵌套目录结构 + +## [0.0.1-alpha.7] - 2026-04-02 + +### Added +- 动态获取子模块路径:从 `.gitmodules` 读取子模块列表 +- AGENTS.md 增加了「自我更新说明」要求 + +### Fixed +- 添加 hatchling build 配置以支持 `uv pip install` +- 更新 `SUBMODULE_PATHS` 支持新增子模块(gallery, qtcloud-finance 等) + +## [0.0.1-alpha.6] - 2026-04-01 + +### Fixed +- 添加 hatchling build 配置以支持 uv pip install + +## [0.0.1-alpha.5] - 2026-04-01 + +### Added +- AGENTS.md 检查增加「自我更新说明」要求 + +## [0.0.1-alpha.4] - 2026-04-01 + +### Added +- 动态获取子模块路径:从 `.gitmodules` 读取子模块列表 + +### Fixed +- 更新 `SUBMODULE_PATHS` 支持新增子模块(gallery, qtcloud-finance 等) + +## [0.0.1-alpha.3] - 2026-04-01 + +### Fixed +- 修复 CLI 入口点配置 +- 添加 `typer` 和 `pyyaml` 依赖项到主 pyproject.toml + +## [0.0.1-alpha.2] - 2026-04-01 + +### Added +- 新增 `asset backup` 命令用于日志归档 +- 新增集成测试和单元测试 + +### Changed +- 重构包结构:将 `qtadmin_cli` 重命名为 `app` +- 重构命令组:将 `meta` 重命名为 `asset`(数字资产职能) +- 更新 ROADMAP + +### Documentation +- 更新用户文档和开发文档 + +## [0.0.1-alpha.1] - 2026-03-28 + +### Added +- 新增 `qtadmin --help` 和 `qtadmin --version` 命令 +- 新增 `qtadmin asset refresh` 命令,同步子模块并提交推送主仓库 + +### Structure +- 使用 typer 构建 CLI +- 雪花编程法:命令模块分离到 `app/asset/` 目录 diff --git a/src/cli/CONTRIBUTING.md b/src/cli/CONTRIBUTING.md new file mode 100644 index 00000000..e4b7ac10 --- /dev/null +++ b/src/cli/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# 版本发布规范 + +## 版本管理 + +单一数据源:仅在 `pyproject.toml` 维护版本号,代码中通过 `importlib.metadata.version()` 动态获取。 + +```python +from importlib.metadata import version + +version("qtadmin-cli") +``` + +## 版本标签 + +使用 `cli/v` 格式: + +```bash +# 创建标签 +git tag cli/v0.0.1-beta.1 + +# 推送标签 +git push origin cli/v0.0.1-beta.1 +``` + +## 发布流程 + +1. 更新 `CHANGELOG.md` - 添加新版本和变更内容 +2. 更新 `pyproject.toml` - 版本号 +3. 提交 CHANGELOG 和 pyproject.toml +4. 创建标签 `git tag cli/v` +5. 推送标签到远程 `git push origin cli/v` +6. 创建 GitHub Release `gh release create cli/v --title "qtadmin-cli v" --generate-notes` + +## 版本规范 + +遵循语义化版本(SemVer): +- alpha: `v0.0.1-alpha.1` +- beta: `v0.0.1-beta.1` +- release: `v0.0.1` \ No newline at end of file diff --git a/src/cli/ROADMAP.md b/src/cli/ROADMAP.md new file mode 100644 index 00000000..4d9994df --- /dev/null +++ b/src/cli/ROADMAP.md @@ -0,0 +1,6 @@ +# ROADMAP + +## v0.0.1 + +- [x] 新增`qtadmin -h`和`qtadmin -v` +- [ ] 增加`qtadmin asset apply`命令:提交所有子仓库的所有更新并提交到云端。 diff --git a/src/cli/app/__init__.py b/src/cli/app/__init__.py new file mode 100644 index 00000000..a96fca4d --- /dev/null +++ b/src/cli/app/__init__.py @@ -0,0 +1,3 @@ +""" +Qtadmin CLI - Quanttide Admin CLI +""" diff --git a/src/cli/app/asset/__init__.py b/src/cli/app/asset/__init__.py new file mode 100644 index 00000000..b6aed1e4 --- /dev/null +++ b/src/cli/app/asset/__init__.py @@ -0,0 +1 @@ +# Asset commands package diff --git a/src/cli/app/asset/audit.py b/src/cli/app/asset/audit.py new file mode 100644 index 00000000..5b98b3e6 --- /dev/null +++ b/src/cli/app/asset/audit.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +""" +Git 仓库资产审计模块 + +根据 docs/handbook/asset/governace/git_repo.md 规范, +检查 Git 仓库是否符合标准资产体系要求。 +""" + +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import typer + + +@dataclass +class AuditResult: + """审计结果""" + + name: str + passed: bool + message: str + suggestion: Optional[str] = None + + +@dataclass +class AuditReport: + """审计报告""" + + repo_path: str + results: list[AuditResult] = field(default_factory=list) + + @property + def passed_count(self) -> int: + return sum(1 for r in self.results if r.passed) + + @property + def failed_count(self) -> int: + return sum(1 for r in self.results if not r.passed) + + @property + def total_count(self) -> int: + return len(self.results) + + @property + def pass_rate(self) -> float: + if self.total_count == 0: + return 0.0 + return self.passed_count / self.total_count * 100 + + def print_report(self, verbose: bool = False): + """打印审计报告""" + print("\n" + "=" * 60) + print("Git 仓库资产审计报告") + print("=" * 60) + print(f"仓库路径:{self.repo_path}") + print( + f"审计结果:{self.passed_count}/{self.total_count} 通过 " + f"({self.pass_rate:.1f}%)" + ) + print("-" * 60) + + # 先显示未通过的项目 + failed_results = [r for r in self.results if not r.passed] + if failed_results: + print("\n❌ 未通过项目:") + for result in failed_results: + print(f"\n [{result.name}]") + print(f" {result.message}") + if result.suggestion: + print(f" 💡 建议:{result.suggestion}") + + # 显示通过的项目 + if verbose: + passed_results = [r for r in self.results if r.passed] + if passed_results: + print("\n✅ 通过项目:") + for result in passed_results: + print(f" ✓ {result.name}") + + print("\n" + "=" * 60) + + if self.failed_count > 0: + print("⚠️ 审计未通过,请根据建议修复问题") + return False + else: + print("✅ 审计通过,仓库符合标准资产体系规范") + return True + + +class GitRepoAuditor: + """Git 仓库审计器""" + + REQUIRED_FILES = { + "README.md": "项目概述、目录结构", + "CONTRIBUTING.md": "贡献指南、工作流、环境变量", + "AGENTS.md": "Agent 导航", + "CHANGELOG.md": "版本历史", + ".gitignore": "Git 忽略规则", + } + + OPTIONAL_DIRS = { + "meta": "元数据目录", + } + + RELEASE_CHECKS = [ + ("CHANGELOG.md", "CHANGELOG.md 是否更新"), + ("pyproject.toml", "版本号是否更新"), + ] + + COMMIT_TYPES = {"feat", "fix", "docs", "test", "refactor", "chore", "style", "perf"} + + def __init__(self, repo_path: str): + self.repo_path = Path(repo_path).resolve() + self._results: list[AuditResult] = [] + + def audit(self) -> AuditReport: + """执行完整审计""" + if not self.repo_path.exists(): + print(f"错误:路径不存在 - {self.repo_path}") + sys.exit(1) + + if not (self.repo_path / ".git").exists(): + print(f"错误:不是 Git 仓库 - {self.repo_path}") + sys.exit(1) + + # 执行各项检查 + self._check_required_files() + self._check_optional_dirs() + self._check_readme_content() + self._check_contributing_content() + self._check_agents_content() + self._check_changelog_format() + self._check_gitignore_content() + self._check_submodules() + self._check_recent_commits() + self._check_release_consistency() + + report = AuditReport(str(self.repo_path)) + report.results = self._results + return report + + def _add_result(self, result: AuditResult): + """添加审计结果""" + self._results.append(result) + + def _check_required_files(self): + """检查必需文件是否存在""" + for filename, description in self.REQUIRED_FILES.items(): + file_path = self.repo_path / filename + passed = file_path.exists() + self._add_result( + AuditResult( + name=f"必需文件:{filename}", + passed=passed, + message=f"{filename} - {description}" + if passed + else f"缺少 {filename}", + suggestion=f"创建 {filename} 文件" if not passed else None, + ) + ) + + def _check_optional_dirs(self): + """检查可选目录""" + for dirname, description in self.OPTIONAL_DIRS.items(): + dir_path = self.repo_path / dirname + passed = dir_path.exists() and dir_path.is_dir() + self._add_result( + AuditResult( + name=f"可选目录:{dirname}/", + passed=passed, + message=f"{dirname}/ - {description}" + if passed + else f"缺少 {dirname}/ 目录", + suggestion=f"考虑创建 {dirname}/ 目录用于存储元数据" + if not passed + else None, + ) + ) + + def _check_readme_content(self): + """检查 README.md 内容""" + readme_path = self.repo_path / "README.md" + if not readme_path.exists(): + return + + content = readme_path.read_text(encoding="utf-8") + + # 检查是否包含项目简介 + has_intro = len(content.split("\n")[0].replace("#", "").strip()) > 0 + + # 检查是否包含目录结构 + has_structure = "目录" in content or "结构" in content or "```" in content + + # 检查是否包含快速开始 + has_quickstart = ( + "快速" in content + or "开始" in content + or "Quick" in content + or "Start" in content + or "开始使用" in content + ) + + passed = has_intro and (has_structure or has_quickstart) + self._add_result( + AuditResult( + name="README.md 内容规范", + passed=passed, + message="包含项目简介、目录结构、快速开始" if passed else "内容不完整", + suggestion="添加项目简介、目录结构和快速开始指南" + if not passed + else None, + ) + ) + + def _check_contributing_content(self): + """检查 CONTRIBUTING.md 内容""" + contrib_path = self.repo_path / "CONTRIBUTING.md" + if not contrib_path.exists(): + return + + content = contrib_path.read_text(encoding="utf-8") + + # 检查关键章节 + required_sections = [ + ("项目结构", ["结构", "目录", "Project Structure"]), + ("开发环境", ["开发", "环境", "Environment", "Setup"]), + ("提交规范", ["提交", "Commit", "规范"]), + ("发布流程", ["发布", "Release", "版本"]), + ] + + missing_sections = [] + for section_name, keywords in required_sections: + has_section = any(kw in content for kw in keywords) + if not has_section: + missing_sections.append(section_name) + + passed = len(missing_sections) == 0 + self._add_result( + AuditResult( + name="CONTRIBUTING.md 内容规范", + passed=passed, + message="包含项目结构、开发环境、提交规范、发布流程" + if passed + else f"缺少章节:{', '.join(missing_sections)}", + suggestion=f"添加缺失的章节:{', '.join(missing_sections)}" + if not passed + else None, + ) + ) + + def _check_agents_content(self): + """检查 AGENTS.md 内容""" + agents_path = self.repo_path / "AGENTS.md" + if not agents_path.exists(): + return + + content = agents_path.read_text(encoding="utf-8") + lines = content.strip().split("\n") + + # 检查行数(建议 ~50 行) + line_count = len(lines) + is_concise = line_count <= 50 + + # 检查是否包含使用场景表格 + has_table = "|" in content and "---" in content + + # 检查是否包含快速索引 + has_index = ( + "索引" in content + or "Index" in content + or "README" in content + or "CONTRIBUTING" in content + ) + + # 检查是否包含自我更新说明(如何更新 AGENTS.md 自身) + has_self_update = ( + ("更新" in content and "AGENTS" in content) + or ("维护" in content and "AGENTS" in content) + or ("self-update" in content.lower()) + or ("how to update" in content.lower()) + ) + + passed = is_concise and (has_table or has_index) and has_self_update + self._add_result( + AuditResult( + name="AGENTS.md 内容规范", + passed=passed, + message=f"简洁 ({line_count}行),包含使用场景、快速索引和自我更新说明" + if passed + else f"需要优化 (共{line_count}行)", + suggestion="保持简洁 (~50 行),添加使用场景表格、快速索引,以及「如何更新 AGENTS.md」的说明" + if not passed + else None, + ) + ) + + def _check_changelog_format(self): + """检查 CHANGELOG.md 格式""" + changelog_path = self.repo_path / "CHANGELOG.md" + if not changelog_path.exists(): + return + + content = changelog_path.read_text(encoding="utf-8") + + # 检查基本格式 + has_changelog_header = "# Changelog" in content or "# CHANGELOG" in content + + # 检查是否有版本记录 + has_version = bool(re.search(r"## \[?v?\d+\.\d+\.\d+", content)) + + # 检查是否有分类标题 + has_sections = any( + section in content + for section in ["### Added", "### Changed", "### Fixed", "### Removed"] + ) + + passed = has_changelog_header and has_version + self._add_result( + AuditResult( + name="CHANGELOG.md 格式规范", + passed=passed, + message="符合语义化版本格式" if passed else "格式不规范", + suggestion="添加 # Changelog 标题和版本号,使用 ### Added/Changed/Fixed/Removed 分类" + if not passed + else None, + ) + ) + + def _check_gitignore_content(self): + """检查 .gitignore 内容""" + gitignore_path = self.repo_path / ".gitignore" + if not gitignore_path.exists(): + return + + content = gitignore_path.read_text(encoding="utf-8") + + # 检查是否包含常见忽略规则 + common_patterns = [ + (".venv", "Python 虚拟环境"), + ("__pycache__", "Python 缓存"), + ("*.pyc", "Python 编译文件"), + (".env", "环境变量文件"), + ] + + found_patterns = [] + for pattern, description in common_patterns: + if pattern in content: + found_patterns.append(f"{pattern} ({description})") + + passed = len(found_patterns) >= 2 # 至少包含 2 个常见规则 + self._add_result( + AuditResult( + name=".gitignore 内容规范", + passed=passed, + message=f"包含 {len(found_patterns)} 个常见规则" + if passed + else "规则较少", + suggestion="添加常见的忽略规则:.venv, __pycache__, *.pyc, .env 等" + if not passed + else None, + ) + ) + + def _check_submodules(self): + """检查子模块配置""" + gitmodules_path = self.repo_path / ".gitmodules" + + if not gitmodules_path.exists(): + self._add_result( + AuditResult( + name="子模块配置", + passed=True, + message="无子模块配置", + suggestion=None, + ) + ) + return + + # 检查 .gitmodules 文件格式 + content = gitmodules_path.read_text(encoding="utf-8") + has_submodule = "[submodule" in content + + # 检查子模块是否已推送(如果有远程) + try: + result = subprocess.run( + ["git", "submodule", "status"], + cwd=self.repo_path, + capture_output=True, + text=True, + timeout=10, + ) + submodule_status = result.stdout.strip() + + # 检查是否有未推送的提交 + unpushed = False + if submodule_status: + for line in submodule_status.split("\n"): + if line.startswith("-") or line.startswith("+"): + unpushed = True + break + + passed = has_submodule and not unpushed + self._add_result( + AuditResult( + name="子模块状态", + passed=passed, + message="子模块配置正确且已推送" + if passed + else "子模块有未推送的提交", + suggestion="请先推送所有子模块的提交,再推送父仓库" + if not passed + else None, + ) + ) + except (subprocess.TimeoutExpired, Exception) as e: + self._add_result( + AuditResult( + name="子模块状态", + passed=False, + message=f"子模块配置存在,状态检查失败 ({e})", + suggestion="请手动检查子模块是否已推送", + ) + ) + + def _check_recent_commits(self): + """检查最近的提交是否符合规范""" + try: + result = subprocess.run( + ["git", "log", "--oneline", "-10"], + cwd=self.repo_path, + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode != 0: + return + + commits = result.stdout.strip().split("\n") + if not commits: + return + + # 检查提交信息格式(Conventional Commits) + # 支持格式:type(scope): description 或 type: description + conventional_pattern = re.compile( + r"^(feat|fix|docs|test|refactor|chore|style|perf)" + r"(\([a-z0-9-]+\))?:\s.+" + ) + + compliant_count = 0 + for commit in commits: + # 跳过空行 + if not commit.strip(): + continue + # 提取提交信息(去掉 hash) + message = commit.split(" ", 1)[1] if " " in commit else commit + if conventional_pattern.match(message.lower()): + compliant_count += 1 + + compliance_rate = compliant_count / len(commits) * 100 if commits else 0 + passed = compliance_rate >= 50 # 至少 50% 符合规范 + + self._add_result( + AuditResult( + name="提交规范符合度", + passed=passed, + message=f"{compliant_count}/{len(commits)} 符合 Conventional Commits " + f"({compliance_rate:.0f}%)", + suggestion="使用 `cz commit` 创建规范提交,或手动遵循 : 格式" + if not passed + else None, + ) + ) + except (subprocess.TimeoutExpired, Exception) as e: + self._add_result( + AuditResult( + name="提交规范符合度", + passed=True, + message=f"提交检查跳过 ({e})", + suggestion=None, + ) + ) + + def _check_release_consistency(self): + """检查版本发布规范一致性""" + changelog_path = self.repo_path / "CHANGELOG.md" + pyproject_path = self.repo_path / "pyproject.toml" + + if not changelog_path.exists() or not pyproject_path.exists(): + return + + changelog_content = changelog_path.read_text(encoding="utf-8") + pyproject_content = pyproject_path.read_text(encoding="utf-8") + + # 提取 pyproject.toml 中的版本号 + version_match = re.search(r'version\s*=\s*"([^"]+)"', pyproject_content) + if not version_match: + return + + pyproject_version = version_match.group(1) + + # 检查 CHANGELOG 中是否有对应版本 + changelog_has_version = bool( + re.search(rf"## \[?{re.escape(pyproject_version)}\]?", changelog_content) + ) + + # 检查最近提交中是否有版本发布相关提交 + try: + result = subprocess.run( + ["git", "log", "--oneline", "-20"], + cwd=self.repo_path, + capture_output=True, + text=True, + timeout=10, + ) + recent_commits = result.stdout.strip() + has_version_commit = bool( + re.search( + rf"bump.*{re.escape(pyproject_version)}|v{re.escape(pyproject_version)}", + recent_commits, + re.IGNORECASE, + ) + ) + except (subprocess.TimeoutExpired, Exception): + has_version_commit = True + + passed = changelog_has_version and has_version_commit + self._add_result( + AuditResult( + name="版本发布规范一致性", + passed=passed, + message="CHANGELOG 和 pyproject.toml 版本一致,且有版本提交" + if passed + else f"CHANGELOG 缺少 v{pyproject_version} 或缺少版本提交", + suggestion="发布前确保:1) 更新 CHANGELOG.md 2) 更新 pyproject.toml 3) 提交版本更新", + ) + ) + + +def audit( + repo_path: str = typer.Argument(".", help="要审计的 Git 仓库路径"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="显示所有通过的项目"), +) -> bool: + """ + 审计 Git 仓库是否符合标准资产体系规范 + + 检查项目包括:必需文件、可选目录、README/CONTRIBUTING/AGENTS/CHANGELOG 内容规范、 + .gitignore 规则、子模块状态、提交规范符合度 + + Returns: + 是否通过审计 + """ + auditor = GitRepoAuditor(repo_path) + report = auditor.audit() + passed = report.print_report(verbose) + if not passed: + raise typer.Exit(code=1) + return True diff --git a/src/cli/app/asset/backup.py b/src/cli/app/asset/backup.py new file mode 100644 index 00000000..fb5e1f9f --- /dev/null +++ b/src/cli/app/asset/backup.py @@ -0,0 +1,281 @@ +""" +Asset backup command + +将 docs/journal/ 下的日志移动到 docs/archive/journal/ 目录。 +""" + +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +import typer + +# 日期文件名正则 +DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}\.md$") + + +@dataclass +class BackupResult: + """backup 操作结果""" + + success: bool + message: str + moved_count: int = 0 + dry_run: bool = False + + +app = typer.Typer(help="将 journal 日志归档到 archive") + + +def get_project_root() -> Path: + """获取项目根目录(包含 docs/journal 和 docs/archive/journal 的目录)""" + current = Path.cwd() + while current != current.parent: + journal = current / "docs" / "journal" + archive = current / "docs" / "archive" / "journal" + if journal.exists() and archive.exists(): + return current + current = current.parent + return Path.cwd() + + +def parse_date_from_filename(filename: str) -> Optional[datetime]: + """从文件名解析日期""" + if not DATE_PATTERN.match(filename): + return None + date_str = filename.replace(".md", "") + try: + return datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + return None + + +def scan_journal_files(journal_dir: Path) -> list[tuple[Path, datetime, str]]: + """ + 递归扫描 journal 目录下的所有日期文件 + + 返回:[(文件路径, 日期, 分类), ...] + """ + files = [] + if not journal_dir.exists(): + typer.echo(f"错误:journal 目录不存在: {journal_dir}") + raise typer.Exit(1) + + for file_path in journal_dir.rglob("*.md"): + if file_path.name.startswith("."): + continue + if not file_path.is_file(): + continue + + date = parse_date_from_filename(file_path.name) + if not date: + continue + + # 分类:取第一层目录名 + parts = file_path.relative_to(journal_dir).parts + category = parts[0] if len(parts) > 1 else "default" + + files.append((file_path, date, category)) + + return files + + +def filter_old_files( + files: list[tuple[Path, datetime, str]], days: int +) -> list[tuple[Path, datetime, str]]: + """筛选 N 天前的文件""" + cutoff_date = datetime.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=days) + return [ + (path, date, category) for path, date, category in files if date < cutoff_date + ] + + +def move_files( + files: list[tuple[Path, datetime, str]], + archive_dir: Path, + journal_dir: Path, + project_root: Path, + dry_run: bool, +) -> list[tuple[Path, Path]]: + """移动文件到 archive 目录,保持嵌套结构""" + moved = [] + for source, date, category in files: + # 保持嵌套目录结构 + rel_parts = source.relative_to(journal_dir).parts[1:-1] # 去掉分类名和文件名 + target_dir = ( + archive_dir / category / "/".join(rel_parts) + if rel_parts + else archive_dir / category + ) + target = target_dir / source.name + + if target.exists(): + typer.echo(f"跳过(已存在):{target.relative_to(project_root)}") + continue + + if dry_run: + typer.echo( + f"[DRY-RUN] {source.relative_to(project_root)} -> {target.relative_to(project_root)}" + ) + else: + target_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(target)) + typer.echo( + f"已移动:{source.relative_to(project_root)} -> {target.relative_to(project_root)}" + ) + + moved.append((source, target)) + + return moved + + +def run_git_command( + cmd: list[str], cwd: Path, project_root: Path +) -> subprocess.CompletedProcess: + """运行 git 命令""" + typer.echo(f"执行:git {' '.join(cmd[1:])} (在 {cwd.relative_to(project_root)})") + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +def check_git_status(repo_path: Path, project_root: Path) -> bool: + """检查是否有未提交的变更""" + result = run_git_command(["git", "status", "--porcelain"], repo_path, project_root) + return bool(result.stdout.strip()) + + +def commit_and_push( + repo_path: Path, message: str, project_root: Path, push: bool = True +) -> bool: + """提交并推送子模块变更""" + if not check_git_status(repo_path, project_root): + typer.echo(f"无变更:{repo_path.relative_to(project_root)}") + return False + + run_git_command(["git", "add", "-A"], repo_path, project_root) + + result = run_git_command(["git", "commit", "-m", message], repo_path, project_root) + if result.returncode != 0: + typer.echo(f"提交失败:{result.stderr}") + return False + + if push: + result = run_git_command( + ["git", "push", "origin", "main"], repo_path, project_root + ) + if result.returncode != 0: + typer.echo(f"推送失败:{result.stderr}") + return False + typer.echo(f"已推送:{repo_path.relative_to(project_root)}") + else: + typer.echo(f"已提交(未推送):{repo_path.relative_to(project_root)}") + + return True + + +def update_submodule_in_main_repo( + submodule_name: str, message: str, project_root: Path, push: bool = True +): + """在主仓库中更新子模块引用""" + run_git_command(["git", "add", submodule_name], project_root, project_root) + + if not check_git_status(project_root, project_root): + typer.echo(f"主仓库无变更:{submodule_name}") + return + + result = run_git_command( + ["git", "commit", "-m", message], project_root, project_root + ) + if result.returncode != 0: + typer.echo(f"主仓库提交失败:{result.stderr}") + return + + if push: + result = run_git_command( + ["git", "push", "origin", "main"], project_root, project_root + ) + if result.returncode != 0: + typer.echo(f"主仓库推送失败:{result.stderr}") + return + typer.echo(f"主仓库已推送:{submodule_name}") + + +@app.command() +def backup( + days: int = typer.Option(3, "--days", help="归档 N 天前的日志(默认 3)"), + dry_run: bool = typer.Option(False, "--dry-run", help="预览模式,不执行实际变更"), + no_push: bool = typer.Option(False, "--no-push", help="仅提交不推送"), + yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认直接执行"), +): + """ + 将 journal 日志归档到 archive。 + + 用法: + qtadmin asset backup # 归档 3 天前的日志 + qtadmin asset backup --days 7 # 归档 7 天前的日志 + qtadmin asset backup --dry-run # 预览模式 + """ + project_root = get_project_root() + journal_dir = project_root / "docs" / "journal" + archive_dir = project_root / "docs" / "archive" / "journal" + + typer.echo(f"项目根目录:{project_root}") + typer.echo(f"Journal 目录:{journal_dir}") + typer.echo(f"Archive 目录:{archive_dir}") + typer.echo(f"归档条件:{days} 天前\n") + + # 扫描文件 + all_files = scan_journal_files(journal_dir) + typer.echo(f"扫描到 {len(all_files)} 个日志文件") + + # 筛选旧文件 + old_files = filter_old_files(all_files, days) + if not old_files: + typer.echo(f"没有 {days} 天前的日志需要归档。") + raise typer.Exit(0) + + # 确认执行 + if not dry_run and not yes: + typer.echo(f"\n共找到 {len(old_files)} 个待归档文件:") + for path, date, category in sorted(old_files, key=lambda x: x[1]): + typer.echo(f" {date.strftime('%Y-%m-%d')} [{category}] {path.name}") + + if not typer.confirm("\n确认执行归档?"): + typer.echo("已取消。") + raise typer.Exit(0) + + # 移动文件 + typer.echo("\n开始归档...") + moved = move_files(old_files, archive_dir, journal_dir, project_root, dry_run) + + if dry_run: + typer.echo(f"\n[DRY-RUN] 共 {len(moved)} 个文件将被归档。") + raise typer.Exit(0) + + if not moved: + typer.echo("没有文件被移动。") + raise typer.Exit(0) + + # 提交子模块 + typer.echo("\n提交子模块变更...") + commit_message = f"archive: backup journal logs older than {days} days" + push = not no_push + + commit_and_push(journal_dir, commit_message, project_root, push) + commit_and_push(archive_dir, commit_message, project_root, push) + + # 更新主仓库子模块引用 + typer.echo("\n更新主仓库子模块引用...") + update_submodule_in_main_repo( + "journal", f"Update journal submodule: {commit_message}", project_root, push + ) + update_submodule_in_main_repo( + "archive", f"Update archive submodule: {commit_message}", project_root, push + ) + + typer.echo("\n归档完成!") diff --git a/src/cli/app/cli.py b/src/cli/app/cli.py new file mode 100644 index 00000000..347f4fa1 --- /dev/null +++ b/src/cli/app/cli.py @@ -0,0 +1,42 @@ +""" +Qtadmin CLI +""" + +import typer +from importlib.metadata import version + +from app.asset import backup as asset_backup +from app.asset import audit as asset_audit +from app.human import cli as human_cli + + +app = typer.Typer(no_args_is_help=True, invoke_without_command=True) + +asset_app = typer.Typer(help="数字资产职能") +asset_app.command()(asset_backup.backup) +asset_app.command()(asset_audit.audit) + +app.add_typer(asset_app, name="asset") +app.add_typer(human_cli.app, name="human") + + +@app.callback(invoke_without_command=True) +def callback( + show_version: bool = typer.Option( + None, "--version", is_flag=True, help="显示版本号" + ), +): + """ + Quanttide Admin CLI + """ + if show_version: + typer.echo(f"qtadmin-cli {version('qtadmin-cli')}") + raise typer.Exit() + + +def main(): + app() + + +if __name__ == "__main__": + main() diff --git a/src/cli/app/human/__init__.py b/src/cli/app/human/__init__.py new file mode 100644 index 00000000..f0e4b910 --- /dev/null +++ b/src/cli/app/human/__init__.py @@ -0,0 +1 @@ +"""Human module: recruitment email classification and ingestion.""" diff --git a/src/cli/app/human/api_client.py b/src/cli/app/human/api_client.py new file mode 100644 index 00000000..f99fde6d --- /dev/null +++ b/src/cli/app/human/api_client.py @@ -0,0 +1,78 @@ +"""HTTP client for communicating with the qtadmin provider HR API.""" +import httpx + + +class ApiClient: + """Client for the qtadmin provider HR API.""" + + def __init__(self, base_url: str = "http://127.0.0.1:8080") -> None: + self._base_url = base_url.rstrip("/") + + def ingest(self, source: str, items: list[dict]) -> dict: + """POST /ingest — push classified emails to pending queue.""" + r = httpx.post(f"{self._base_url}/ingest", json={"source": source, "items": items}) + if r.status_code != 201: + raise RuntimeError(f"Ingest failed (HTTP {r.status_code}): {r.text}") + return r.json() + + def get_queue_stats(self) -> dict[str, int]: + """GET /queue/stats — get pending/confirmed/ignored counts.""" + r = httpx.get(f"{self._base_url}/queue/stats") + if r.status_code != 200: + raise RuntimeError(f"Queue stats failed (HTTP {r.status_code}): {r.text}") + return r.json() + + def claim_outbox(self) -> dict: + """POST /messages/outbox/claim — claim pending outbox messages.""" + r = httpx.post(f"{self._base_url}/messages/outbox/claim", timeout=30) + r.raise_for_status() + return r.json() + + def get_outbox_detail(self, mid: int, lease_id: str) -> dict: + """GET /messages/outbox/{id}?lease_id= — get full message detail.""" + r = httpx.get( + f"{self._base_url}/messages/outbox/{mid}", + params={"lease_id": lease_id}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + def update_send_status( + self, mid: int, lease_id: str, status: str, + platform_message_id: str = "", failure_reason: str = "", + ) -> int: + """PATCH /messages/{id}/send-status — update send status. + + Returns HTTP status code (200=ok, 409=conflict). + """ + body = {"lease_id": lease_id, "send_status": status} + if platform_message_id: + body["platform_message_id"] = platform_message_id + if failure_reason: + body["failure_reason"] = failure_reason + r = httpx.patch( + f"{self._base_url}/messages/{mid}/send-status", + json=body, + timeout=30, + ) + return r.status_code + + def get_outbox_count(self, status: str | None = None) -> int: + """GET /messages/outbox — count outbox messages, optionally filtered by status.""" + params = {"status": status} if status else {} + r = httpx.get(f"{self._base_url}/messages/outbox", params=params, timeout=30) + r.raise_for_status() + return r.json()["count"] + + def list_dead_letters(self) -> list[dict]: + """GET /messages/outbox/dead — list dead letters.""" + r = httpx.get(f"{self._base_url}/messages/outbox/dead", timeout=30) + r.raise_for_status() + return r.json() + + def requeue_dead_letter(self, message_id: int) -> dict: + """POST /messages/outbox/{id}/requeue — reset dead letter to pending.""" + r = httpx.post(f"{self._base_url}/messages/outbox/{message_id}/requeue", timeout=30) + r.raise_for_status() + return r.json() diff --git a/src/cli/app/human/classifier.py b/src/cli/app/human/classifier.py new file mode 100644 index 00000000..0a9804aa --- /dev/null +++ b/src/cli/app/human/classifier.py @@ -0,0 +1,35 @@ +"""Keyword-based email classifier for recruitment emails.""" + + +_RULES: list[tuple[list[str], str]] = [ + (["应聘", "求职"], "contacted"), + (["笔试答案", "答题", "试卷"], "exam_received"), + (["面试感谢", "面试反馈", "面试结果"], "interview"), + (["放弃", "退出", "辞职", "离职"], "closed"), +] + +_HEADHUNTER_DOMAINS = ["liepin", "zhaopin", "51job", "hunter", "猎聘"] +_HEADHUNTER_BODY_KEYWORDS = ["推荐候选人"] + + +def classify(subject: str, body: str = "", sender_email: str = "") -> tuple[str | None, str]: + """Classify a recruitment email. + + Returns (suggested_status, confidence). + """ + for keywords, status in _RULES: + for kw in keywords: + if kw in subject: + return (status, "high") + + if sender_email: + domain = sender_email.split("@")[-1].lower() if "@" in sender_email else "" + for hd in _HEADHUNTER_DOMAINS: + if hd in domain: + return ("contacted", "low") + + for kw in _HEADHUNTER_BODY_KEYWORDS: + if kw in body: + return ("contacted", "low") + + return (None, "low") diff --git a/src/cli/app/human/cli.py b/src/cli/app/human/cli.py new file mode 100644 index 00000000..c924acb2 --- /dev/null +++ b/src/cli/app/human/cli.py @@ -0,0 +1,277 @@ +"""Human CLI commands — recruitment email classification and ingestion.""" +import json +import logging +import sys + +import httpx +import typer + +from app.human.api_client import ApiClient +from app.human.classifier import classify +from app.human.config import Config +from app.human.lark_client import LarkClient + +app = typer.Typer(help="人力资源职能:招聘邮件处理") +config_app = typer.Typer(help="查看和修改人力资源模块配置") + + +@config_app.command(name="set-provider") +def config_set_provider(url: str = typer.Argument(..., help="服务端地址,如 http://127.0.0.1:8000")): + """配置服务端地址。""" + Config().set("provider_url", url) + typer.echo(f"服务端地址已设为: {url}") + + +@config_app.command(name="set-lark-path") +def config_set_lark_path(path: str = typer.Argument(..., help="lark-cli 路径")): + """配置 lark-cli 路径。""" + Config().set("lark_path", path) + typer.echo(f"lark-cli 路径已设为: {path}") + + +@config_app.command(name="show") +def config_show(): + """查看当前配置。""" + cfg = Config().show() + for k, v in cfg.items(): + typer.echo(f" {k} = {v}") + + +app.add_typer(config_app, name="config") + + +@app.command(name="list") +def mail_list( + limit: int = typer.Option(20, "-n", "--limit", help="最大条数"), + since: str = typer.Option("7d", "--since", help="时间范围(7d/24h/日期)"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """列出收件箱中的招聘邮件。""" + cfg = Config() + lark = LarkClient(lark_path=cfg.get("lark_path")) + emails = lark.list_emails(limit=limit, since=since) + + if not emails: + typer.echo("未找到招聘邮件。请确认 lark-cli 已安装并登录。", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps( + [{"mail_id": e.mail_id, "subject": e.subject, "sender": e.sender_name, "date": e.date} + for e in emails], ensure_ascii=False, + )) + return + + typer.echo(f" {'#':>3} │ {'发件人':<8} │ {'主题':<40} │ {'建议阶段':<14} │ {'可信度':<6}") + typer.echo("─────┼──────────┼──────────────────────────────────────────┼────────────────┼────────") + for i, email in enumerate(emails, 1): + status, conf = classify(subject=email.subject, sender_email=email.sender_email) + status_str = status or "待确认" + typer.echo(f" {i:>3} │ {email.sender_name:<8} │ {email.subject:<40} │ {status_str:<14} │ {conf:<6}") + + +@app.command(name="classify") +def mail_classify( + mail_id: str = typer.Argument(..., help="邮件 ID"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """对单封邮件运行分类并预览。""" + cfg = Config() + lark = LarkClient(lark_path=cfg.get("lark_path")) + email = lark.read_email(mail_id) + if not email: + typer.echo(f"邮件 {mail_id} 未找到。用 list 命令查看可用 ID。", err=True) + raise typer.Exit(1) + + status, conf = classify(subject=email.subject, body=email.body, sender_email=email.sender_email) + + if as_json: + typer.echo(json.dumps({ + "mail_id": mail_id, "subject": email.subject, + "sender_name": email.sender_name, "sender_email": email.sender_email, + "suggested_status": status, "confidence": conf, + }, ensure_ascii=False)) + return + + typer.echo(f" 发件人: {email.sender_name} <{email.sender_email}>") + typer.echo(f" 主题: {email.subject}") + typer.echo(f" 建议: {status or '无法分类'} (可信度: {conf})") + + +@app.command(name="ingest") +def mail_ingest( + limit: int = typer.Option(20, "-n", "--limit", help="最多处理条数"), + dry_run: bool = typer.Option(False, "--dry-run", help="只预览,不推送"), + status_filter: str = typer.Option(None, "--status", help="只推送指定阶段的邮件"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """推送分类结果到服务端待确认队列。""" + cfg = Config() + provider_url = cfg.get("provider_url") + if not provider_url: + typer.echo("未配置服务端地址。运行: qtadmin human config set-provider ", err=True) + raise typer.Exit(1) + + lark = LarkClient(lark_path=cfg.get("lark_path")) + emails = lark.list_emails(limit=limit) + + items = [] + for email in emails: + status, conf = classify(subject=email.subject, sender_email=email.sender_email) + if not status: + continue + if status_filter and status != status_filter: + continue + items.append({ + "message_id": email.mail_id, "subject": email.subject, + "sender_name": email.sender_name, "sender_email": email.sender_email or "", + "suggested_status": status, "confidence": conf, + }) + + if dry_run or not items: + if as_json: + typer.echo(json.dumps({"dry_run": True, "count": len(items), "items": items}, ensure_ascii=False)) + return + typer.echo(f"\n {'发件人':<8} │ {'主题':<30} │ {'建议阶段':<14} │ {'可信度':<6}") + typer.echo(" ─────────┼─────────────────────────────────┼────────────────┼────────") + for item in items: + typer.echo(f" {item['sender_name']:<8} │ {item['subject']:<30} │ {item['suggested_status']:<14} │ {item['confidence']:<6}") + if dry_run: + typer.echo(f"\n 预览: {len(items)} 条。去掉 --dry-run 执行推送。", err=True) + else: + typer.echo("没有可推送的邮件。", err=True) + return + + try: + api = ApiClient(base_url=provider_url) + result = api.ingest(source="feishu_api", items=items) + except (httpx.ConnectError, httpx.TimeoutException) as e: + typer.echo(f"连接服务端失败: {e}", err=True) + typer.echo(f"确认服务端已启动且 provider_url 配置正确。", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps(result, ensure_ascii=False)) + return + + typer.echo(f" 已入队列: {result['queued']} 已跳过: {result['skipped']}", err=True) + if result["errors"]: + typer.echo(f" 错误: {len(result['errors'])}", err=True) + typer.echo(f" 数据已在待确认队列,请通过管理后台确认。", err=True) + + +@app.command() +def status( + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """查看待确认队列计数。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + try: + stats = api.get_queue_stats() + except (httpx.ConnectError, httpx.TimeoutException) as e: + typer.echo(f"连接服务端失败: {e}", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps(stats, ensure_ascii=False)) + return + + typer.echo(f" 待确认: {stats.get('pending', 0)}", err=True) + typer.echo(f" 已确认: {stats.get('confirmed', 0)}", err=True) + typer.echo(f" 已忽略: {stats.get('ignored', 0)}", err=True) + + +@app.command(name="send") +def mail_send( + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """领取并发送发件箱中的待发邮件(单次轮询)。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + try: + from app.human.mail_sender import send_pending + sent = send_pending(api) + except httpx.ConnectError as e: + typer.echo(f"连接服务端失败: {e}", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps({"sent": sent}, ensure_ascii=False)) + return + + if sent: + typer.echo(f"已发送 {sent} 封邮件。", err=True) + else: + typer.echo("发件箱中没有待发邮件。", err=True) + + +@app.command(name="send-loop") +def mail_send_loop( + interval: int = typer.Option(30, "-i", "--interval", help="轮询间隔(秒)"), +): + """持续轮询发件箱并发送邮件(守护进程模式)。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + try: + from app.human.mail_sender import run_loop + run_loop(api, interval=interval) + except KeyboardInterrupt: + typer.echo("\n发件循环已停止。", err=True) + + +@app.command(name="outbox") +def mail_outbox( + status: str = typer.Option(None, "--status", help="筛选状态: pending/sending/sent/failed"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """查看发件箱统计。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + count = api.get_outbox_count(status=status) + if as_json: + typer.echo(json.dumps({"count": count, "status": status}, ensure_ascii=False)) + return + label = status or "待发/发送中" + typer.echo(f" {label}: {count} 封", err=True) + + +@app.command(name="dead-letters") +def mail_dead_letters( + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """查看死信队列(发送失败超过最大重试次数)。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + items = api.list_dead_letters() + if as_json: + typer.echo(json.dumps(items, ensure_ascii=False)) + return + + if not items: + typer.echo(" 没有死信。", err=True) + return + + typer.echo(f" {'#':>3} │ {'收件人':<24} │ {'主题':<40} │ {'失败原因':<20} │ {'重试次数'}") + typer.echo(" ─────┼──────────────────────────┼──────────────────────────────────────────┼──────────────────────┼────────────") + for i, item in enumerate(items, 1): + typer.echo(f" {i:>3} │ {item['recipient_email'] or '':<24} │ {item['subject'][:38]:<40} │ {(item['failure_reason'] or '')[:18]:<20} │ {item['retry_count']}") + + +@app.command(name="requeue") +def mail_requeue( + message_id: int = typer.Argument(..., help="死信消息 ID"), +): + """将死信重新放入发件队列。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + try: + result = api.requeue_dead_letter(message_id) + typer.echo(f" 消息 {result['id']} 已重新入队,状态: {result['send_status']}", err=True) + except httpx.HTTPStatusError as e: + typer.echo(f" 操作失败 (HTTP {e.response.status_code}): {e.response.text}", err=True) + raise typer.Exit(1) diff --git a/src/cli/app/human/config.py b/src/cli/app/human/config.py new file mode 100644 index 00000000..8207ca2a --- /dev/null +++ b/src/cli/app/human/config.py @@ -0,0 +1,48 @@ +"""Configuration management for human module.""" +import json +import os + +_DEFAULTS = { + "provider_url": "http://127.0.0.1:8000", + "lark_path": "lark-cli", +} +_CONFIG_PATH = os.path.expanduser("~/.config/qtadmin/human.json") + + +class Config: + """Manages human module config stored as JSON.""" + + def __init__(self, path: str | None = None) -> None: + self._path = path or _CONFIG_PATH + self._data: dict[str, str] = {} + + def _load(self) -> None: + try: + with open(self._path) as f: + raw = json.load(f) + if isinstance(raw, dict): + self._data = {k: str(v) for k, v in raw.items()} + return + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + self._data = {} + + def _save(self) -> None: + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + + def get(self, key: str) -> str: + self._load() + return self._data.get(key, _DEFAULTS.get(key, "")) + + def set(self, key: str, value: str) -> None: + self._load() + self._data[key] = value + self._save() + + def show(self) -> dict[str, str]: + self._load() + merged = dict(_DEFAULTS) + merged.update(self._data) + return merged diff --git a/src/cli/app/human/lark_client.py b/src/cli/app/human/lark_client.py new file mode 100644 index 00000000..b625d560 --- /dev/null +++ b/src/cli/app/human/lark_client.py @@ -0,0 +1,81 @@ +"""Wrapper around lark-cli subprocess.""" +import logging +import subprocess +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class LarkEmail: + mail_id: str + sender_name: str = "" + sender_email: str = "" + subject: str = "" + body: str = "" + date: str = "" + + +class LarkClient: + """Wraps lark-cli commands via subprocess.""" + + def __init__(self, lark_path: str = "lark-cli") -> None: + self._lark_path = lark_path + + def _run(self, cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + result.check_returncode() + return result.stdout + + def list_emails(self, limit: int = 20, since: str = "7d") -> list[LarkEmail]: + cmd = [self._lark_path, "mail", "list", "--limit", str(limit), "--since", since] + raw = self._run(cmd) + return self._parse_list_output(raw) + + def read_email(self, mail_id: str) -> LarkEmail | None: + cmd = [self._lark_path, "mail", "read", mail_id] + raw = self._run(cmd) + return self._parse_read_output(mail_id, raw) + + def _parse_list_output(self, raw: str) -> list[LarkEmail]: + emails: list[LarkEmail] = [] + for line in raw.strip().splitlines(): + parts = line.strip().split(maxsplit=3) + if len(parts) >= 2: + emails.append(LarkEmail( + mail_id=parts[0], + sender_name=parts[1] if len(parts) > 1 else "", + subject=parts[2] if len(parts) > 2 else "", + date=parts[3] if len(parts) > 3 else "", + )) + return emails + + def _parse_read_output(self, mail_id: str, raw: str) -> LarkEmail | None: + if not raw.strip(): + return None + sender_name = "" + sender_email = "" + subject = "" + body = "" + in_body = False + for line in raw.splitlines(): + if line.startswith("From:"): + rest = line[5:].strip() + if "<" in rest and ">" in rest: + sender_name = rest.split("<")[0].strip() + sender_email = rest.split("<")[1].rstrip(">").strip() + else: + sender_name = rest + elif line.startswith("Subject:"): + subject = line[8:].strip() + elif line.startswith("Body:"): + in_body = True + elif in_body: + body += line + "\n" + return LarkEmail( + mail_id=mail_id, + sender_name=sender_name, + sender_email=sender_email, + subject=subject, + body=body.strip(), + ) diff --git a/src/cli/app/human/mail_sender.py b/src/cli/app/human/mail_sender.py new file mode 100644 index 00000000..5b5b3f23 --- /dev/null +++ b/src/cli/app/human/mail_sender.py @@ -0,0 +1,88 @@ +"""飞书邮件发送:从 outbox 获取待发邮件,通过 lark-cli 发送。""" + +import json +import logging +import subprocess +import time + +logger = logging.getLogger(__name__) + + +def _lark_send(recipient: str, subject: str, body: str) -> dict: + """调用 lark-cli mail +send 发送邮件,返回解析后的 JSON。""" + cmd = [ + "lark-cli", "mail", "+send", + "--to", recipient, + "--subject", subject, + "--body", body, + "--confirm-send", + "--format", "json", + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + result.check_returncode() + return json.loads(result.stdout) + + +def send_pending(api) -> int: + """Claim outbox messages and send them via lark-cli. Returns number sent.""" + sent_count = 0 + data = api.claim_outbox() + claimed = data.get("claimed", []) + + if not claimed: + logger.info("No pending messages to send.") + return 0 + + for msg in claimed: + mid = msg["id"] + lease_id = msg["lease_id"] + recipient = msg.get("recipient_email", "") + + if not recipient: + logger.warning("Message %d has no recipient_email, skipping", mid) + continue + + detail = api.get_outbox_detail(mid, lease_id) + body = detail.get("body_text") or detail.get("body") or "" + + try: + logger.info("Sending message %d to %s: %s", mid, recipient, msg["subject"]) + lark_resp = _lark_send(recipient, msg["subject"], body) + platform_id = "" + if isinstance(lark_resp, dict): + platform_id = lark_resp.get("data", {}).get("id", "") + if not platform_id: + platform_id = lark_resp.get("id", str(lark_resp)) + + status_code = api.update_send_status( + mid, lease_id, "sent", + platform_message_id=platform_id, + ) + if status_code == 409: + logger.warning("Message %d lease_id mismatch (concurrent send?)", mid) + else: + sent_count += 1 + logger.info("Message %d sent successfully (platform_id=%s)", mid, platform_id) + + except subprocess.CalledProcessError as e: + err_msg = e.stderr or str(e) + logger.error("lark-cli failed for message %d: %s", mid, err_msg) + api.update_send_status(mid, lease_id, "failed", failure_reason=err_msg[:500]) + except Exception as e: + logger.error("Unexpected error for message %d: %s", mid, str(e)) + api.update_send_status(mid, lease_id, "failed", failure_reason=str(e)[:500]) + + return sent_count + + +def run_loop(api, interval: int = 30): + """Continuous send loop.""" + logger.info("Mail sender loop started (interval=%ds)", interval) + while True: + try: + n = send_pending(api) + if n: + logger.info("Sent %d messages this cycle", n) + except Exception as e: + logger.error("Send cycle failed: %s", str(e)) + time.sleep(interval) diff --git a/src/cli/app/human/mail_sender_loop.py b/src/cli/app/human/mail_sender_loop.py new file mode 100644 index 00000000..07448982 --- /dev/null +++ b/src/cli/app/human/mail_sender_loop.py @@ -0,0 +1,23 @@ +"""systemd entry point — runs the mail sender polling loop. + +Usage: + python -m app.human.mail_sender_loop + +Environment variables: + QTADMIN_SERVER_URL — provider base URL (default: http://localhost:8080) +""" +import logging +import os + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", +) + +if __name__ == "__main__": + from app.human.api_client import ApiClient + from app.human.mail_sender import run_loop + + server_url = os.environ.get("QTADMIN_SERVER_URL", "http://localhost:8080") + api = ApiClient(base_url=server_url) + run_loop(api) diff --git a/src/cli/docs/dev/asset_audit.md b/src/cli/docs/dev/asset_audit.md new file mode 100644 index 00000000..f661cf1a --- /dev/null +++ b/src/cli/docs/dev/asset_audit.md @@ -0,0 +1,44 @@ +# Asset Audit 开发文档 + +## 模块概述 + +Git 仓库资产审计模块,检查仓库是否符合标准资产体系规范。 + +## 检查项目 + +| 检查项 | 说明 | +|--------|------| +| 必需文件 | README.md, CONTRIBUTING.md, AGENTS.md, CHANGELOG.md, .gitignore | +| 可选目录 | meta/ | +| README 内容 | 项目简介、目录结构、快速开始 | +| CONTRIBUTING 内容 | 项目结构、开发环境、提交规范、发布流程 | +| AGENTS 内容 | 简洁(≤50行)、使用场景表格、快速索引、自我更新说明 | +| CHANGELOG 格式 | 语义化版本格式,有版本号和分类标题 | +| .gitignore 规则 | 至少包含 2 个常见规则 | +| 子模块状态 | 配置正确且已推送 | +| 提交规范 | 最近 10 条提交至少 50% 符合 Conventional Commits | +| 版本发布一致性 | CHANGELOG 和 pyproject.toml 版本一致,且有版本提交 | + +## 提交规范正则 + +```python +r'^(feat|fix|docs|test|refactor|chore|style|perf)(\([a-z0-9-]+\))?:\s.+' +``` + +支持格式: +- `type: description` +- `type(scope): description` + +## 版本发布一致性检查 + +检查逻辑: +1. 提取 `pyproject.toml` 中的版本号 +2. 验证 `CHANGELOG.md` 中是否有对应版本记录 +3. 检查最近提交中是否有版本发布相关提交(bump/version tag) + +## 已知问题 + +| 问题 | 状态 | +|------|------| +| 默认路径处理 | 待修复 | +| 缺少自动修复功能 | 待开发 | diff --git a/src/cli/docs/dev/asset_backup.md b/src/cli/docs/dev/asset_backup.md new file mode 100644 index 00000000..6e7f9b42 --- /dev/null +++ b/src/cli/docs/dev/asset_backup.md @@ -0,0 +1,58 @@ +# Asset Backup 开发文档 + +## 模块概述 + +将 `docs/journal/` 下的日志归档到 `docs/archive/journal/` 目录。 + +## 核心逻辑 + +### 扫描逻辑 + +使用 `rglob("*.md")` 递归扫描所有子目录,支持任意嵌套层级: + +```python +for file_path in journal_dir.rglob("*.md"): + parts = file_path.relative_to(journal_dir).parts + category = parts[0] if len(parts) > 1 else "default" +``` + +### 分类提取 + +从相对路径的第一层目录名提取分类: + +| 路径 | 分类 | +|------|------| +| `docs/journal/qtclass/train/2026-03-26.md` | qtclass | +| `docs/journal/default/qtclass/2026-03-18.md` | default | +| `docs/journal/organization/2026-03-25.md` | organization | + +### 归档目录结构 + +保持原始嵌套层级: + +```python +rel_parts = source.relative_to(journal_dir).parts[1:-1] # 去掉分类名和文件名 +target_dir = archive_dir / category / "/".join(rel_parts) +``` + +示例: +- `docs/journal/qtclass/train/2026-03-26.md` → `docs/archive/journal/qtclass/train/2026-03-26.md` + +## 函数说明 + +| 函数 | 说明 | +|------|------| +| `get_project_root()` | 向上查找包含 docs/journal 和 docs/archive/journal 的目录 | +| `parse_date_from_filename()` | 从 `YYYY-MM-DD.md` 文件名解析日期 | +| `scan_journal_files()` | 递归扫描所有日期文件 | +| `filter_old_files()` | 筛选 N 天前的文件 | +| `move_files()` | 移动文件,保持嵌套结构 | +| `commit_and_push()` | 提交并推送子模块变更 | +| `update_submodule_in_main_repo()` | 更新主仓库子模块引用 | + +## 已知问题 + +| 问题 | 状态 | +|------|------| +| 扫描逻辑只支持单层目录 | 已修复 | +| 归档后目录结构丢失 | 已修复 | diff --git a/src/cli/docs/ops/asset_audit.md b/src/cli/docs/ops/asset_audit.md new file mode 100644 index 00000000..e5e5410b --- /dev/null +++ b/src/cli/docs/ops/asset_audit.md @@ -0,0 +1,90 @@ +# Asset Audit 问题排查 + +## 问题描述 + +审计工具 `qtadmin asset audit` 本身存在的问题。 + +## 已发现问题 + +### 1. 提交规范检测模式不完整 + +**位置**: `audit.py:389-401` + +```python +conventional_pattern = re.compile( + r'^[a-z]+\([a-z-]+\)?:|^feat:|^fix:|^docs:|^test:|^refactor:|^chore:|^style:|^perf:' +) +``` + +**问题**: +- 正则表达式混乱:`^[a-z]+\([a-z-]+\)?:` 和 `^feat:` 同时存在 +- 匹配效率低,容易遗漏格式如 `docs(handbook):` 的提交 + +### 2. 默认仓库路径处理 + +**位置**: `audit.py:424` + +```python +def audit(repo_path: str = typer.Argument(".", help="要审计的 Git 仓库路径")) +``` + +**问题**: +- 默认值 `.` 不支持参数时使用当前工作目录 +- 用户运行 `qtadmin asset audit`(无参数)会使用默认值而非当前目录 + +### 3. 子模块检查超时处理 + +**位置**: `audit.py:362-368` + +```python +except (subprocess.TimeoutExpired, Exception) e: + self._add_result(AuditResult( + name="子模块状态", + passed=has_submodule, + message=f"子模块配置存在,状态检查跳过 ({e})", + suggestion=None + )) +``` + +**问题**: +- 超时时返回 `passed=True`,掩盖了实际问题 +- 应该返回 `passed=False` 或警告状态 + +### 4. AGENTS.md 行数阈值过宽 + +**位置**: `audit.py:238` + +```python +is_concise = line_count <= 100 # 宽松一点,不超过 100 行 +``` + +**建议**: +- 阈值应为 50 行,而非 100 行 +- 注释已说明"宽松一点",但不符合原始需求 + +### 5. 缺少对审计结果的自动修复功能 + +**问题**: +- 只提供建议,无法自动修复 +- 用户需要手动创建缺失的文件 + +### 6. 无法审计版本发布规范 + +**问题**: +- 审计工具只能检查提交规范,无法验证版本发布是否符合规范 +- 导致无法发现 AI 不遵守发布规范导致的问题(如未更新 CHANGELOG.md、未打标签等) +- monorepo 项目需要检查多个子模块的发布规范执行情况 + +**影响**: +- 版本发布遗漏 CHANGELOG 更新 +- 标签命名不符合规范(如应为 `cli/v0.0.1` 却打成 `v0.0.1`) +- 子模块未正确推送就更新主仓库引用 + +## 待办 + +- [x] 修复正则表达式,统一匹配逻辑 +- [ ] 修改默认路径为实际当前工作目录 +- [x] 超时时返回失败状态或警告 +- [x] 调整 AGENTS.md 行数阈值至 50 行 +- [ ] 添加 `--fix` 选项自动修复常见问题 +- [x] 添加版本发布规范审计功能 \ No newline at end of file diff --git a/src/cli/docs/ops/asset_backup.md b/src/cli/docs/ops/asset_backup.md new file mode 100644 index 00000000..baf5cd61 --- /dev/null +++ b/src/cli/docs/ops/asset_backup.md @@ -0,0 +1,96 @@ +# Asset Backup 问题排查 + +## 问题描述 + +`qtadmin asset backup` 只扫描到 1 个文件,但实际应有更多文件需要归档。 + +## 环境信息 + +- 日期:2026-04-01 +- 归档条件:3 天前 + +## 实际文件结构 + +``` +docs/journal/ +├── default/qtclass/2026-03-18.md # 14天前 +├── knowl/qtclass/2026-03-18.md # 14天前 +├── qtclass/train/2026-03-26.md # 6天前 +└── stdn/business/2026-03-18.md # 14天前 +``` + +## 根本原因 + +### 扫描逻辑缺陷 + +`scan_journal_files()` 函数只遍历**一层**子目录: + +```python +for category_dir in journal_dir.iterdir(): # 只遍历直接子目录 + if not category_dir.is_dir(): + continue + category = category_dir.name + for file_path in category_dir.iterdir(): # 只扫描这一层 + ... +``` + +**实际结构 vs 代码假设**: + +| 实际路径 | 嵌套层数 | 是否被扫描 | +|---------|---------|-----------| +| `docs/journal/organization/2026-03-25.md` | 1层 | ✓ | +| `docs/journal/qtclass/train/2026-03-26.md` | 2层 | ✗ | +| `docs/journal/default/qtclass/2026-03-18.md` | 2层 | ✗ | + +代码假设日志文件直接在分类目录下(如 `docs/journal/qtclass/`),但实际嵌套更深。 + +## 解决方案 + +### 方案:递归扫描所有子目录 + +```python +def scan_journal_files(journal_dir: Path) -> list[tuple[Path, datetime, str]]: + """递归扫描 journal 目录下所有日期文件""" + files = [] + if not journal_dir.exists(): + return files + + for file_path in journal_dir.rglob("*.md"): # 递归扫描所有 .md + if file_path.name.startswith("."): + continue + + date = parse_date_from_filename(file_path.name) + if not date: + continue + + # 分类:取第二层目录名 + parts = file_path.relative_to(journal_dir).parts + category = parts[0] if len(parts) > 1 else "default" + + files.append((file_path, date, category)) + + return files +``` + +### 归档后移动到对应目录 + +移动时按嵌套层级保持结构: + +```python +def move_files(...): + for source, date, category in files: + # 保持嵌套结构 + target_dir = archive_dir / category + # 如果有子分类,也保留 + parts = source.relative_to(journal_dir).parts[1:-1] + target_dir = target_dir / "/".join(parts) if parts else target_dir + ... +``` + +## 待办 + +- [x] 修改 `scan_journal_files()` 支持递归扫描 +- [x] 修复分类逻辑(从路径提取正确的分类) +- [x] 保持嵌套目录结构 +- [x] 添加单元测试覆盖嵌套目录场景 +- [x] 更新文档 `src/cli/docs/dev/asset_backup.md` \ No newline at end of file diff --git a/src/cli/docs/user/asset_backup.md b/src/cli/docs/user/asset_backup.md new file mode 100644 index 00000000..40f28ad8 --- /dev/null +++ b/src/cli/docs/user/asset_backup.md @@ -0,0 +1,96 @@ +# qtadmin asset backup + +将 journal 日志归档到 archive。 + +## 使用方法 + +```bash +# 归档 3 天前的日志(默认) +qtadmin asset backup + +# 归档 7 天前的日志 +qtadmin asset backup --days 7 + +# 预览模式,不执行实际变更 +qtadmin asset backup --dry-run + +# 仅提交不推送 +qtadmin asset backup --no-push + +# 跳过确认直接执行 +qtadmin asset backup -y +``` + +## 示例 + +### 预览模式 + +```bash +$ qtadmin asset backup --dry-run +项目根目录:/home/user/quanttide-founder +Journal 目录:/home/user/quanttide-founder/docs/journal +Archive 目录:/home/user/quanttide-founder/docs/archive/journal +归档条件:3 天前 + +扫描到 38 个日志文件 + +开始归档... +[DRY-RUN] docs/journal/default/2026-03-26.md -> docs/archive/journal/default/2026-03-26.md +[DRY-RUN] docs/journal/write/2026-03-24.md -> docs/archive/journal/write/2026-03-24.md + +[DRY-RUN] 共 17 个文件将被归档。 +``` + +### 执行归档 + +```bash +$ qtadmin asset backup -y +项目根目录:/home/user/quanttide-founder +Journal 目录:/home/user/quanttide-founder/docs/journal +Archive 目录:/home/user/quanttide-founder/docs/archive/journal +归档条件:3 天前 + +扫描到 38 个日志文件 + +开始归档... +已移动:docs/journal/default/2026-03-26.md -> docs/archive/journal/default/2026-03-26.md + +提交子模块变更... +执行:git add -A (在 docs/journal) +已推送:docs/journal + +更新主仓库子模块引用... +执行:git add journal (在 .) +主仓库已推送:journal + +归档完成! +``` + +## 流程 + +1. 递归扫描 `docs/journal/` 下所有日期文件(`YYYY-MM-DD.md`),支持任意嵌套目录 +2. 筛选 N 天前的日志 +3. 移动文件到 `docs/archive/journal/{category}/` 对应目录,保持原始嵌套结构 +4. 跳过已存在的目标文件 +5. 提交并推送 journal 和 archive 子模块 +6. 更新主仓库子模块引用 + +## 目录结构示例 + +``` +docs/journal/ +├── qtclass/train/2026-03-26.md # 分类: qtclass, 嵌套: train +└── default/2026-03-18.md # 分类: default + +归档后: +docs/archive/journal/ +├── qtclass/train/2026-03-26.md # 保持嵌套结构 +└── default/2026-03-18.md +``` + +## 注意事项 + +- 支持任意嵌套目录层级,归档后保持原始结构 +- 目标文件已存在时会自动跳过 +- 使用 `--dry-run` 预览将要归档的文件 +- 默认会提示确认,使用 `-y` 跳过确认 diff --git a/src/cli/pyproject.toml b/src/cli/pyproject.toml new file mode 100644 index 00000000..6038f493 --- /dev/null +++ b/src/cli/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "qtadmin-cli" +version = "0.0.1" +description = "Quanttide Admin CLI" +requires-python = ">=3.10" +dependencies = [ + "typer>=0.12.0", + "pyyaml>=6.0.1", + "httpx>=0.27.0", +] + +[project.scripts] +qtadmin = "app.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", +] + +[tool.setuptools] +package-dir = {"" = "."} +packages = ["app"] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/src/cli/src/qtadmin/__init__.py b/src/cli/src/qtadmin/__init__.py new file mode 100644 index 00000000..c6b63e88 --- /dev/null +++ b/src/cli/src/qtadmin/__init__.py @@ -0,0 +1 @@ +"""Compatibility wrapper exposing the human CLI under the qtadmin package name.""" diff --git a/src/cli/src/qtadmin/__main__.py b/src/cli/src/qtadmin/__main__.py new file mode 100644 index 00000000..a1beedc4 --- /dev/null +++ b/src/cli/src/qtadmin/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as python -m qtadmin.""" + +from qtadmin.cli import main + +main() diff --git a/src/cli/src/qtadmin/api_client.py b/src/cli/src/qtadmin/api_client.py new file mode 100644 index 00000000..c32f7ab1 --- /dev/null +++ b/src/cli/src/qtadmin/api_client.py @@ -0,0 +1 @@ +from app.human.api_client import * # noqa: F403 diff --git a/src/cli/src/qtadmin/classifier.py b/src/cli/src/qtadmin/classifier.py new file mode 100644 index 00000000..003ee241 --- /dev/null +++ b/src/cli/src/qtadmin/classifier.py @@ -0,0 +1 @@ +from app.human.classifier import * # noqa: F403 diff --git a/src/cli/src/qtadmin/cli.py b/src/cli/src/qtadmin/cli.py new file mode 100644 index 00000000..aec7567c --- /dev/null +++ b/src/cli/src/qtadmin/cli.py @@ -0,0 +1,349 @@ +"""qtadmin CLI — HR recruitment email classification tool. + +Supports: mail list, mail classify, mail ingest, mail send, status. +""" + +import json +import logging +import os +import sys +import time + +import click + +from qtadmin.api_client import ApiClient +from qtadmin.classifier import classify +from qtadmin.config import ConfigManager +from qtadmin.lark_client import LarkClient +from qtadmin.mail_sender import send_pending + +__version__ = "2.0.0" + +_CONFIG_PATH = os.path.expanduser("~/.config/qtadmin/config.json") +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") + + +def _eprint(*args: object, **kwargs: object) -> None: + print(*args, file=sys.stderr, **kwargs) + + +def _get_cfg() -> ConfigManager: + return ConfigManager(_CONFIG_PATH) + + +def _get_api(cfg: ConfigManager) -> ApiClient: + url = cfg.get("provider_url") + if not url: + _eprint("Provider URL not configured. Run: qtadmin config set-provider ") + sys.exit(1) + return ApiClient(base_url=url) + + +def _get_lark(cfg: ConfigManager) -> LarkClient: + return LarkClient(lark_path=cfg.get("lark_path")) + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.version_option(version=__version__, prog_name="qtadmin", message="qtadmin %(version)s") +def cli() -> None: + """qtadmin — HR recruitment email classification tool. + + Wraps lark-cli to pull recruitment emails, classify them, + and push to the qtadmin provider pending queue. + + Note: Local classification is for preview only. + The authoritative classification happens server-side. + """ + + +@cli.group() +def config() -> None: + """Manage configuration (stored in ~/.config/qtadmin/config.json).""" + + +@config.command(name="set-provider") +@click.argument("url") +def config_set_provider(url: str) -> None: + """Set provider server URL. Example: http://localhost:8000""" + _get_cfg().set("provider_url", url) + _eprint(f"✓ Provider URL set to {url}") + + +@config.command(name="set-lark-path") +@click.argument("path") +def config_set_lark_path(path: str) -> None: + """Set lark-cli path (if not in PATH).""" + _get_cfg().set("lark_path", path) + _eprint(f"✓ lark-cli path set to {path}") + + +@config.command(name="set-mailbox") +@click.argument("email") +def config_set_mailbox(email: str) -> None: + """Set Feishu mailbox address.""" + _get_cfg().set("mailbox", email) + _eprint(f"✓ Mailbox set to {email}") + + +@config.command() +def show() -> None: + """Show current configuration.""" + data = _get_cfg().show() + click.echo(json.dumps(data, indent=2, ensure_ascii=False)) + + +@cli.group() +def human() -> None: + """HR business operations.""" + + +@human.group() +def mail() -> None: + """Mail operations: list, classify, ingest, send.""" + + +@mail.command(name="list") +@click.option("-n", "--limit", default=20, show_default=True, help="Max emails to list") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON (for piping)") +def mail_list(limit: int, as_json: bool) -> None: + cfg = _get_cfg() + lark = _get_lark(cfg) + emails = lark.list_emails(limit=limit, mailbox=cfg.get("mailbox")) + + if not emails: + _eprint("No emails found. Make sure lark-cli is installed and logged in.") + return + + if as_json: + click.echo( + json.dumps( + [ + { + "mail_id": e.mail_id, + "subject": e.subject, + "sender": e.sender_name, + "sender_email": e.sender_email, + "date": e.date, + } + for e in emails + ], + ensure_ascii=False, + ) + ) + return + + click.echo(" ⚠ 以下分类结果为本地预览,最终分类以服务端为准\n") + click.echo(f" {'#':>3} │ {'发件人':<8} │ {'主题':<40} │ {'建议状态':<14} │ {'置信度':<6}") + click.echo("─────┼──────────┼──────────────────────────────────────────┼────────────────┼────────") + for i, email in enumerate(emails, 1): + result = classify(subject=email.subject, sender_name=email.sender_name, sender_email=email.sender_email) + status = result.suggested_status or "待确认" + click.echo(f" {i:>3} │ {email.sender_name:<8} │ {email.subject:<40} │ {status:<14} │ {result.confidence:<6}") + + +@mail.command(name="classify") +@click.argument("mail_id") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def mail_classify(mail_id: str, as_json: bool) -> None: + cfg = _get_cfg() + lark = _get_lark(cfg) + email = lark.read_email(mail_id, mailbox=cfg.get("mailbox")) + if not email: + _eprint(f"Email '{mail_id}' not found. Verify the ID with 'qtadmin human mail list'.") + sys.exit(1) + + result = classify( + subject=email.subject, + body=email.body_plain_text or email.body, + sender_name=email.sender_name, + sender_email=email.sender_email, + ) + + if as_json: + click.echo( + json.dumps( + { + "mail_id": mail_id, + "subject": email.subject, + "sender_name": email.sender_name, + "sender_email": email.sender_email, + "suggested_status": result.suggested_status, + "confidence": result.confidence, + "suggested_position": result.suggested_position, + "extracted_name": result.extracted_name, + "extracted_email": result.extracted_email, + "extracted_phone": result.extracted_phone, + }, + ensure_ascii=False, + ) + ) + return + + click.echo(f" 发件人: {email.sender_name} <{email.sender_email}>") + click.echo(f" 主题: {email.subject}") + click.echo(f" 建议状态: {result.suggested_status or '无法自动分类'} (置信度: {result.confidence})") + if result.suggested_position: + click.echo(f" 建议职位: {result.suggested_position}") + if result.extracted_name: + click.echo(f" 提取姓名: {result.extracted_name}") + if result.extracted_phone: + click.echo(f" 提取电话: {result.extracted_phone}") + click.echo("\n ⚠ 本地预览,最终分类以服务端为准") + + +@mail.command(name="ingest") +@click.option("-n", "--limit", default=20, show_default=True, help="Max emails to process") +@click.option("--dry-run", is_flag=True, help="Preview what would be pushed") +@click.option("--status", default=None, help="Only push emails matching this status") +@click.option("--with-content", is_flag=True, default=True, help="Fetch full body + attachments") +@click.option("--json", "as_json", is_flag=True, help="Output result as JSON") +def mail_ingest(dry_run: bool, limit: int, status: str | None, with_content: bool, as_json: bool) -> None: + cfg = _get_cfg() + api = _get_api(cfg) + lark = _get_lark(cfg) + mailbox = cfg.get("mailbox") + + emails = lark.list_emails(limit=limit, mailbox=mailbox) + + items = [] + for email in emails: + detail = lark.read_email(email.mail_id, mailbox=mailbox) + + body_text = detail.body_plain_text if detail else "" + body_html = detail.body if detail else "" + attachments = detail.attachments if detail else [] + + result = classify( + subject=email.subject, + body=body_text or body_html, + sender_name=email.sender_name, + sender_email=email.sender_email, + ) + + if status and result.suggested_status != status: + continue + + raw_attachments = [] + for attachment in attachments: + raw_attachments.append( + { + "filename": attachment.get("filename", ""), + "size": attachment.get("size", 0), + "mime_type": attachment.get("content_type"), + "message_attachment_id": attachment.get("id"), + } + ) + + item = { + "message_id": email.mail_id, + "subject": email.subject, + "sender_name": email.sender_name, + "sender_email": email.sender_email, + "body": body_html, + "body_text": body_text, + "attachments": raw_attachments, + "extracted_name": result.extracted_name, + "extracted_email": result.extracted_email, + "extracted_phone": result.extracted_phone, + } + + suggested_recruitment_title = result.suggested_position + if suggested_recruitment_title: + item["suggested_recruitment_title"] = suggested_recruitment_title + + items.append(item) + + if dry_run or not items: + if as_json: + click.echo(json.dumps({"dry_run": True, "count": len(items), "items": items}, ensure_ascii=False)) + return + click.echo("\n ⚠ 以下为本地预览,最终分类以服务端为准\n") + click.echo(f" {'发件人':<8} │ {'主题':<30} │ {'附件':<6}") + click.echo(" ─────────┼─────────────────────────────────┼────────") + for item in items: + click.echo(f" {item['sender_name']:<8} │ {item['subject']:<30} │ {len(item.get('attachments', []))}") + if dry_run: + _eprint(f"\n Preview: {len(items)} items ready. Remove --dry-run to push.") + else: + _eprint("No matching emails to push.") + return + + result = api.ingest(source="feishu_api", items=items) + + if as_json: + click.echo(json.dumps(result, ensure_ascii=False)) + return + + _eprint(f" Queued: {result['queued']}, Skipped: {result['skipped']}") + if result.get("errors"): + _eprint(f" Errors: {len(result['errors'])}") + _eprint(f" Total: {len(items)}") + _eprint(" Data is now in the pending queue. Confirm via API or studio.") + + +@mail.command(name="send") +@click.option("--loop", is_flag=True, help="Run in continuous polling loop") +@click.option("-i", "--interval", default=30, show_default=True, help="Polling interval in seconds (--loop only)") +def mail_send(loop: bool, interval: int) -> None: + cfg = _get_cfg() + api = _get_api(cfg) + lark = _get_lark(cfg) + + if loop: + _eprint(f"Mail sender loop started (interval={interval}s)") + while True: + try: + sent = send_pending(api, lark) + if sent: + _eprint(f"Sent {sent} messages this cycle") + except Exception as exc: + _eprint(f"Send cycle failed: {exc}") + time.sleep(interval) + + sent = send_pending(api, lark) + _eprint(f"Sent {sent} messages") + + +class StatusGroup(click.MultiCommand): + def list_commands(self, ctx: click.Context) -> list[str]: + return ["pending", "last-ingest"] + + def get_command(self, ctx: click.Context, name: str) -> click.Command | None: + if name == "pending": + return _status_pending + if name == "last-ingest": + return _status_last_ingest + return None + + +@click.command(name="pending", help="Show pending queue counts") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def _status_pending(as_json: bool) -> None: + cfg = _get_cfg() + api = _get_api(cfg) + stats = api.get_queue_stats() + + if as_json: + click.echo(json.dumps(stats, ensure_ascii=False)) + return + + _eprint(f" Pending: {stats.get('pending', 0)}") + _eprint(f" Confirmed: {stats.get('confirmed', 0)}") + _eprint(f" Ignored: {stats.get('ignored', 0)}") + + +@click.command(name="last-ingest", help="Show last ingest result") +def _status_last_ingest() -> None: + _eprint("Not yet implemented. Requires server-side batch tracking.") + sys.exit(1) + + +human.add_command(StatusGroup(name="status", help="Check server status.")) + + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/src/cli/src/qtadmin/config.py b/src/cli/src/qtadmin/config.py new file mode 100644 index 00000000..741d774f --- /dev/null +++ b/src/cli/src/qtadmin/config.py @@ -0,0 +1 @@ +from app.human.config import * # noqa: F403 diff --git a/src/cli/src/qtadmin/lark_client.py b/src/cli/src/qtadmin/lark_client.py new file mode 100644 index 00000000..12cca392 --- /dev/null +++ b/src/cli/src/qtadmin/lark_client.py @@ -0,0 +1 @@ +from app.human.lark_client import * # noqa: F403 diff --git a/src/cli/src/qtadmin/mail_sender.py b/src/cli/src/qtadmin/mail_sender.py new file mode 100644 index 00000000..309f748d --- /dev/null +++ b/src/cli/src/qtadmin/mail_sender.py @@ -0,0 +1 @@ +from app.human.mail_sender import * # noqa: F403 diff --git a/src/cli/tests/__init__.py b/src/cli/tests/__init__.py new file mode 100644 index 00000000..d4839a6b --- /dev/null +++ b/src/cli/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/src/cli/tests/test_audit.py b/src/cli/tests/test_audit.py new file mode 100644 index 00000000..e4d98fa2 --- /dev/null +++ b/src/cli/tests/test_audit.py @@ -0,0 +1,640 @@ +""" +qtadmin asset audit 命令测试 +""" + +import pytest +from unittest.mock import patch, MagicMock, mock_open +from pathlib import Path +import sys +import os +import typer + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from app.asset.audit import ( + AuditResult, + AuditReport, + GitRepoAuditor, + audit, +) + + +class TestAuditResult: + """测试 AuditResult 数据类""" + + def test_audit_result_default(self): + """测试默认值""" + result = AuditResult(name="Test", passed=True, message="OK") + assert result.name == "Test" + assert result.passed is True + assert result.message == "OK" + assert result.suggestion is None + + def test_audit_result_with_suggestion(self): + """测试带建议的审计结果""" + result = AuditResult( + name="Test", + passed=False, + message="Failed", + suggestion="Fix it" + ) + assert result.name == "Test" + assert result.passed is False + assert result.message == "Failed" + assert result.suggestion == "Fix it" + + +class TestAuditReport: + """测试 AuditReport 数据类""" + + def test_auditrt_default(self): + """测试默认值""" + report = AuditReport(repo_path="/tmp/repo") + assert report.repo_path == "/tmp/repo" + assert report.total_count == 0 + assert report.passed_count == 0 + assert report.failed_count == 0 + assert report.pass_rate == 0.0 + + def test_auditrt_with_results(self): + """测试带结果的审计报告""" + report = AuditReport(repo_path="/tmp/repo") + report.results = [ + AuditResult(name="Test1", passed=True, message="OK"), + AuditResult(name="Test2", passed=False, message="Failed", suggestion="Fix"), + AuditResult(name="Test3", passed=True, message="OK"), + ] + assert report.total_count == 3 + assert report.passed_count == 2 + assert report.failed_count == 1 + assert report.pass_rate == pytest.approx(66.666, rel=0.1) + + def test_auditrt_empty_results(self): + """测试空结果的通过率""" + report = AuditReport(repo_path="/tmp/repo") + assert report.pass_rate == 0.0 + + @patch("app.asset.audit.print") + def test_auditrt_print_success(self, mock_print): + """测试打印成功的审计报告""" + report = AuditReport(repo_path="/tmp/repo") + report.results = [ + AuditResult(name="Test1", passed=True, message="OK"), + ] + result = report.print_report(verbose=False) + assert result is True + mock_print.assert_called() + + @patch("app.asset.audit.print") + def test_auditrt_print_failure(self, mock_print): + """测试打印失败的审计报告""" + report = AuditReport(repo_path="/tmp/repo") + report.results = [ + AuditResult(name="Test1", passed=False, message="Failed", suggestion="Fix"), + ] + result = report.print_report(verbose=False) + assert result is False + mock_print.assert_called() + + @patch("app.asset.audit.print") + def test_auditrt_print_verbose(self, mock_print): + """测试打印详细审计报告""" + report = AuditReport(repo_path="/tmp/repo") + report.results = [ + AuditResult(name="Test1", passed=True, message="OK"), + AuditResult(name="Test2", passed=False, message="Failed"), + ] + report.print_report(verbose=True) + mock_print.assert_called() + + +class TestGitRepoAuditorInit: + """测试 GitRepoAuditor 初始化""" + + def test_init_with_string_path(self): + """测试使用字符串路径初始化""" + auditor = GitRepoAuditor("/tmp/repo") + assert str(auditor.repo_path) == "/tmp/repo" + + def test_init_with_path_object(self): + """测试使用 Path 对象初始化""" + auditor = GitRepoAuditor(Path("/tmp/repo")) + assert str(auditor.repo_path) == "/tmp/repo" + + def test_init_resolves_path(self): + """测试路径解析""" + auditor = GitRepoAuditor(".") + assert auditor.repo_path.is_absolute() + + +class TestGitRepoAuditorAudit: + """测试 GitRepoAuditor.audit() 方法""" + + @patch("pathlib.Path.exists") + def test_audit_nonexistent_path(self, mock_exists): + """测试审计不存在的路径""" + mock_exists.return_value = False + auditor = GitRepoAuditor("/nonexistent/path") + with pytest.raises(SystemExit) as exc_info: + auditor.audit() + assert exc_info.value.code == 1 + + @patch("pathlib.Path.exists") + def test_audit_non_git_repo(self, mock_exists): + """测试审计非 Git 仓库""" + mock_exists.return_value = False + auditor = GitRepoAuditor("/tmp/not-a-repo") + with pytest.raises(SystemExit) as exc_info: + auditor.audit() + assert exc_info.value.code == 1 + + +class TestGitRepoAuditorRequiredFiles: + """测试必需文件检查""" + + @patch("pathlib.Path.exists") + def test_all_required_files_exist(self, mock_exists): + """测试所有必需文件存在""" + mock_exists.return_value = True + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_required_files() + + assert len(auditor._results) == 5 + for result in auditor._results: + assert result.passed is True + + @patch("pathlib.Path.exists") + def test_missing_required_files(self, mock_exists): + """测试缺少必需文件""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_required_files() + + assert len(auditor._results) == 5 + for result in auditor._results: + assert result.passed is False + assert "缺少" in result.message + + @patch("pathlib.Path.exists") + def test_some_required_files_missing(self, mock_exists): + """测试部分必需文件缺失""" + # 简单 mock:所有文件都不存在 + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_required_files() + + # 所有检查都应该失败 + failed_results = [r for r in auditor._results if not r.passed] + assert len(failed_results) == 5 + + +class TestGitRepoAuditorOptionalDirs: + """测试可选目录检查""" + + @patch("pathlib.Path.exists") + @patch("pathlib.Path.is_dir") + def test_optional_dir_exists(self, mock_is_dir, mock_exists): + """测试可选目录存在""" + mock_exists.return_value = True + mock_is_dir.return_value = True + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_optional_dirs() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("pathlib.Path.exists") + def test_optional_dir_missing(self, mock_exists): + """测试可选目录缺失""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_optional_dirs() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + assert "缺少" in auditor._results[0].message + + +class TestGitRepoAuditorReadmeContent: + """测试 README.md 内容检查""" + + @patch("pathlib.Path.exists") + def test_readme_not_exists(self, mock_exists): + """测试 README 不存在""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_readme_content() + + assert len(auditor._results) == 0 + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_readme_complete(self, mock_exists, mock_read_text): + """测试 README 内容完整""" + mock_exists.return_value = True + mock_read_text.return_value = """# Project Title + +项目简介 + +## 目录结构 + +``` +src/ +tests/ +``` + +## 快速开始 + +安装依赖... +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_readme_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_readme_incomplete(self, mock_exists, mock_read_text): + """测试 README 内容不完整""" + mock_exists.return_value = True + mock_read_text.return_value = """# Project Title +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_readme_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + + +class TestGitRepoAuditorContributingContent: + """测试 CONTRIBUTING.md 内容检查""" + + @patch("pathlib.Path.exists") + def test_contributing_not_exists(self, mock_exists): + """测试 CONTRIBUTING 不存在""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_contributing_content() + + assert len(auditor._results) == 0 + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_contributing_complete(self, mock_exists, mock_read_text): + """测试 CONTRIBUTING 内容完整""" + mock_exists.return_value = True + mock_read_text.return_value = """# Contributing + +## 项目结构 + +目录说明 + +## 开发环境 + +环境配置 + +## 提交规范 + +使用 Conventional Commits + +## 发布流程 + +版本发布步骤 +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_contributing_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_contributing_missing_sections(self, mock_exists, mock_read_text): + """测试 CONTRIBUTING 缺少章节""" + mock_exists.return_value = True + mock_read_text.return_value = """# Contributing + +一些内容 +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_contributing_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + assert "缺少章节" in auditor._results[0].message + + +class TestGitRepoAuditorAgentsContent: + """测试 AGENTS.md 内容检查""" + + @patch("pathlib.Path.exists") + def test_agents_not_exists(self, mock_exists): + """测试 AGENTS 不存在""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_agents_content() + + assert len(auditor._results) == 0 + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_agents_concise_with_table(self, mock_exists, mock_read_text): + """测试 AGENTS 简洁且有表格""" + mock_exists.return_value = True + content = """# Agents + +| 任务 | 查看 | +|------|------| +| 测试 | README | + +快速索引 +如何更新 AGENTS 文件 +""" + mock_read_text.return_value = content + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_agents_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_agents_too_long(self, mock_exists, mock_read_text): + """测试 AGENTS 太长""" + mock_exists.return_value = True + content = "# Agents\n" + "\n".join([f"Line {i}" for i in range(150)]) + mock_read_text.return_value = content + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_agents_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + + +class TestGitRepoAuditorChangelogFormat: + """测试 CHANGELOG.md 格式检查""" + + @patch("pathlib.Path.exists") + def test_changelog_not_exists(self, mock_exists): + """测试 CHANGELOG 不存在""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_changelog_format() + + assert len(auditor._results) == 0 + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_changelog_valid_format(self, mock_exists, mock_read_text): + """测试 CHANGELOG 格式正确""" + mock_exists.return_value = True + mock_read_text.return_value = """# Changelog + +## [0.1.0] - 2024-01-15 + +### Added +- Feature 1 + +### Changed +- Change 1 +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_changelog_format() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_changelog_invalid_format(self, mock_exists, mock_read_text): + """测试 CHANGELOG 格式无效""" + mock_exists.return_value = True + mock_read_text.return_value = """Some random content +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_changelog_format() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + + +class TestGitRepoAuditorGitignoreContent: + """测试 .gitignore 内容检查""" + + @patch("pathlib.Path.exists") + def test_gitignore_not_exists(self, mock_exists): + """测试 .gitignore 不存在""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_gitignore_content() + + assert len(auditor._results) == 0 + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_gitignore_complete(self, mock_exists, mock_read_text): + """测试 .gitignore 内容完整""" + mock_exists.return_value = True + mock_read_text.return_value = """# Python +.venv/ +__pycache__/ +*.pyc + +# Environment +.env +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_gitignore_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_gitignore_minimal(self, mock_exists, mock_read_text): + """测试 .gitignore 内容不足""" + mock_exists.return_value = True + mock_read_text.return_value = """# Only one rule +*.log +""" + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_gitignore_content() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + + +class TestGitRepoAuditorSubmodules: + """测试子模块检查""" + + @patch("pathlib.Path.exists") + def test_no_gitmodules(self, mock_exists): + """测试没有 .gitmodules 文件""" + mock_exists.return_value = False + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_submodules() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + assert "无子模块配置" in auditor._results[0].message + + @patch("subprocess.run") + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_submodules_clean(self, mock_exists, mock_read_text, mock_run): + """测试子模块状态正常""" + # .gitmodules 存在 + mock_exists.return_value = True + mock_read_text.return_value = '[submodule "test"]' + mock_run.return_value = MagicMock(stdout="", returncode=0) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_submodules() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("subprocess.run") + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_submodules_unpushed(self, mock_exists, mock_read_text, mock_run): + """测试子模块有未推送的提交""" + # .gitmodules 存在 + mock_exists.return_value = True + mock_read_text.return_value = '[submodule "test"]' + mock_run.return_value = MagicMock(stdout="-abc123 test", returncode=0) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_submodules() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + + @patch("subprocess.run") + @patch("pathlib.Path.read_text") + @patch("pathlib.Path.exists") + def test_submodules_timeout(self, mock_exists, mock_read_text, mock_run): + """测试子模块检查超时""" + from subprocess import TimeoutExpired + # .gitmodules 存在 + mock_exists.return_value = True + mock_read_text.return_value = '[submodule "test"]' + mock_run.side_effect = TimeoutExpired("git submodule status", 10) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_submodules() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False # 超时视为检查失败 + + +class TestGitRepoAuditorRecentCommits: + """测试最近提交检查""" + + @patch("subprocess.run") + def test_commits_all_compliant(self, mock_run): + """测试所有提交符合规范""" + # git log --oneline 输出格式是 "hash message" + mock_run.return_value = MagicMock( + stdout="abc123 feat: add feature\ndef456 fix: fix bug\n789abc docs: update docs", + returncode=0 + ) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_recent_commits() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is True + + @patch("subprocess.run") + def test_commits_some_compliant(self, mock_run): + """测试部分提交符合规范""" + mock_run.return_value = MagicMock( + stdout="abc123 feat: add feature\ndef456 bad commit\n789abc fix: fix bug\n012345 another bad one", + returncode=0 + ) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_recent_commits() + + # 50% 符合率,应该通过 + assert len(auditor._results) == 1 + + @patch("subprocess.run") + def test_commits_none_compliant(self, mock_run): + """测试没有提交符合规范""" + mock_run.return_value = MagicMock( + stdout="abc123 bad commit 1\ndef456 bad commit 2\n789abc bad commit 3", + returncode=0 + ) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_recent_commits() + + assert len(auditor._results) == 1 + assert auditor._results[0].passed is False + + @patch("subprocess.run") + def test_commits_error(self, mock_run): + """测试获取提交失败""" + mock_run.return_value = MagicMock(returncode=1) + + auditor = GitRepoAuditor("/tmp/repo") + auditor._check_recent_commits() + + # 错误时不添加结果 + assert len(auditor._results) == 0 + + +class TestAuditRepo: + """测试 audit 函数""" + + @patch("app.asset.audit.GitRepoAuditor") + def test_audit_success(self, mock_auditor_class): + """测试成功审计""" + mock_report = MagicMock() + mock_report.print_report.return_value = True + mock_auditor = MagicMock() + mock_auditor.audit.return_value = mock_report + mock_auditor_class.return_value = mock_auditor + + result = audit("/tmp/repo", verbose=False) + + mock_auditor_class.assert_called_once_with("/tmp/repo") + mock_auditor.audit.assert_called_once() + # 成功时返回 True + assert result is True + + @patch("app.asset.audit.GitRepoAuditor") + def test_audit_failure(self, mock_auditor_class): + """测试审计失败""" + mock_report = MagicMock() + mock_report.print_report.return_value = False + mock_auditor = MagicMock() + mock_auditor.audit.return_value = mock_report + mock_auditor_class.return_value = mock_auditor + + # 失败时抛出 Exit 异常 + with pytest.raises(typer.Exit): + audit("/tmp/repo", verbose=False) diff --git a/src/cli/tests/test_backup.py b/src/cli/tests/test_backup.py new file mode 100644 index 00000000..4d8d2a8e --- /dev/null +++ b/src/cli/tests/test_backup.py @@ -0,0 +1,554 @@ +""" +qtadmin asset backup 命令测试 +""" + +import pytest +from unittest.mock import patch, MagicMock, mock_open +from pathlib import Path +from datetime import datetime, timedelta +from dataclasses import dataclass +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from app.asset.backup import ( + BackupResult, + DATE_PATTERN, + parse_date_from_filename, + scan_journal_files, + filter_old_files, + move_files, + run_git_command, + check_git_status, + commit_and_push, + update_submodule_in_main_repo, + get_project_root, +) + + +class TestDatePattern: + """测试日期文件名正则""" + + def test_valid_date_filename(self): + """测试有效的日期文件名""" + assert DATE_PATTERN.match("2024-01-15.md") is not None + assert DATE_PATTERN.match("2024-12-31.md") is not None + assert DATE_PATTERN.match("2000-01-01.md") is not None + + def test_invalid_date_filename(self): + """测试无效的日期文件名""" + assert DATE_PATTERN.match("2024-1-15.md") is None # 月份不是两位 + assert DATE_PATTERN.match("24-01-15.md") is None # 年份不是四位 + assert DATE_PATTERN.match("2024-01-15.txt") is None # 扩展名错误 + assert DATE_PATTERN.match("journal-2024-01-15.md") is None # 前缀错误 + assert DATE_PATTERN.match("2024-01-15-backup.md") is None # 后缀错误 + + +class TestParseDateFromFilename: + """测试文件名日期解析""" + + def test_valid_dates(self): + """测试有效的日期解析""" + result = parse_date_from_filename("2024-01-15.md") + assert result == datetime(2024, 1, 15) + + result = parse_date_from_filename("2024-12-31.md") + assert result == datetime(2024, 12, 31) + + def test_invalid_dates(self): + """测试无效的日期解析""" + assert parse_date_from_filename("invalid.md") is None + assert parse_date_from_filename("2024-13-01.md") is None # 无效月份 + assert parse_date_from_filename("2024-02-30.md") is None # 无效日期 + assert parse_date_from_filename("not-a-date.txt") is None + + def test_edge_cases(self): + """测试边界情况""" + # 空字符串 + assert parse_date_from_filename("") is None + # 只有扩展名 + assert parse_date_from_filename(".md") is None + + +class TestScanJournalFiles: + """测试扫描 journal 文件""" + + @patch("app.asset.backup.typer.echo") + def test_journal_dir_not_exists(self, mock_echo): + """测试 journal 目录不存在""" + with pytest.raises(Exception) as exc_info: + scan_journal_files(Path("/nonexistent/path")) + assert exc_info.value.exit_code == 1 + + @patch("app.asset.backup.typer.echo") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.rglob") + def test_scan_files_flat(self, mock_rglob, mock_exists, mock_echo): + """测试扫描单层目录文件""" + mock_exists.return_value = True + + mock_file1 = MagicMock() + mock_file1.is_file.return_value = True + mock_file1.name = "2024-01-15.md" + mock_file1.relative_to.return_value = Path("work/2024-01-15.md") + + mock_file2 = MagicMock() + mock_file2.is_file.return_value = True + mock_file2.name = "2024-01-16.md" + mock_file2.relative_to.return_value = Path("work/2024-01-16.md") + + mock_rglob.return_value = [mock_file1, mock_file2] + + journal_dir = Path("/tmp/journal") + files = scan_journal_files(journal_dir) + + assert len(files) == 2 + assert files[0][2] == "work" + assert files[0][1] == datetime(2024, 1, 15) + assert files[1][1] == datetime(2024, 1, 16) + + @patch("app.asset.backup.typer.echo") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.rglob") + def test_scan_files_nested(self, mock_rglob, mock_exists, mock_echo): + """测试扫描嵌套目录文件""" + mock_exists.return_value = True + + mock_file1 = MagicMock() + mock_file1.is_file.return_value = True + mock_file1.name = "2024-01-15.md" + mock_file1.relative_to.return_value = Path("qtclass/train/2024-01-15.md") + + mock_file2 = MagicMock() + mock_file2.is_file.return_value = True + mock_file2.name = "2024-01-16.md" + mock_file2.relative_to.return_value = Path("default/qtclass/2024-01-16.md") + + mock_rglob.return_value = [mock_file1, mock_file2] + + journal_dir = Path("/tmp/journal") + files = scan_journal_files(journal_dir) + + assert len(files) == 2 + assert files[0][2] == "qtclass" + assert files[1][2] == "default" + + @patch("app.asset.backup.typer.echo") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.rglob") + def test_skip_hidden_files(self, mock_rglob, mock_exists, mock_echo): + """测试跳过隐藏文件""" + mock_exists.return_value = True + + mock_hidden = MagicMock() + mock_hidden.is_file.return_value = True + mock_hidden.name = ".hidden.md" + mock_hidden.relative_to.return_value = Path("work/.hidden.md") + + mock_rglob.return_value = [mock_hidden] + + files = scan_journal_files(Path("/tmp/journal")) + assert len(files) == 0 + + @patch("app.asset.backup.typer.echo") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.rglob") + def test_skip_non_date_files(self, mock_rglob, mock_exists, mock_echo): + """测试跳过非日期文件""" + mock_exists.return_value = True + + mock_file = MagicMock() + mock_file.is_file.return_value = True + mock_file.name = "readme.md" + mock_file.relative_to.return_value = Path("work/readme.md") + + mock_rglob.return_value = [mock_file] + + files = scan_journal_files(Path("/tmp/journal")) + assert len(files) == 0 + + @patch("app.asset.backup.typer.echo") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.rglob") + def test_default_category_for_root_files(self, mock_rglob, mock_exists, mock_echo): + """测试根目录文件使用 default 分类""" + mock_exists.return_value = True + + mock_file = MagicMock() + mock_file.is_file.return_value = True + mock_file.name = "2024-01-15.md" + mock_file.relative_to.return_value = Path("2024-01-15.md") + + mock_rglob.return_value = [mock_file] + + files = scan_journal_files(Path("/tmp/journal")) + assert len(files) == 1 + assert files[0][2] == "default" + + +class TestFilterOldFiles: + """测试筛选旧文件""" + + def test_filter_by_days(self): + """测试按天数筛选""" + now = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + files = [ + (Path("2024-01-01.md"), now - timedelta(days=5), "work"), # 5 天前 + (Path("2024-01-02.md"), now - timedelta(days=2), "work"), # 2 天前 + (Path("2024-01-03.md"), now - timedelta(days=10), "work"), # 10 天前 + ] + + # 筛选 3 天前的文件 + result = filter_old_files(files, days=3) + assert len(result) == 2 + assert result[0][0].name == "2024-01-01.md" + assert result[1][0].name == "2024-01-03.md" + + def test_no_old_files(self): + """测试没有旧文件""" + now = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + files = [ + (Path("2024-01-01.md"), now - timedelta(hours=1), "work"), # 1 小时前 + (Path("2024-01-02.md"), now, "work"), # 今天 + ] + + result = filter_old_files(files, days=1) + assert len(result) == 0 + + def test_all_old_files(self): + """测试所有文件都旧""" + now = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + files = [ + (Path("2024-01-01.md"), now - timedelta(days=10), "work"), + (Path("2024-01-02.md"), now - timedelta(days=20), "work"), + ] + + result = filter_old_files(files, days=3) + assert len(result) == 2 + + +class TestMoveFiles: + """测试移动文件""" + + @patch("app.asset.backup.typer.echo") + @patch("shutil.move") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.exists") + def test_move_files_success(self, mock_exists, mock_mkdir, mock_move, mock_echo): + """测试成功移动文件""" + project_root = Path("/tmp/project") + journal_dir = project_root / "docs" / "journal" + archive_dir = project_root / "docs" / "archive" / "journal" + + files = [ + ( + project_root / "docs" / "journal" / "work" / "2024-01-15.md", + datetime(2024, 1, 15), + "work", + ), + ] + + mock_exists.return_value = False + + moved = move_files(files, archive_dir, journal_dir, project_root, dry_run=False) + + assert len(moved) == 1 + mock_mkdir.assert_called() + mock_move.assert_called() + + @patch("app.asset.backup.typer.echo") + @patch("shutil.move") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.exists") + def test_move_files_nested(self, mock_exists, mock_mkdir, mock_move, mock_echo): + """测试移动嵌套目录文件""" + project_root = Path("/tmp/project") + journal_dir = project_root / "docs" / "journal" + archive_dir = project_root / "docs" / "archive" / "journal" + + files = [ + ( + project_root + / "docs" + / "journal" + / "qtclass" + / "train" + / "2024-01-15.md", + datetime(2024, 1, 15), + "qtclass", + ), + ] + + mock_exists.return_value = False + + moved = move_files(files, archive_dir, journal_dir, project_root, dry_run=False) + + assert len(moved) == 1 + # 验证目标路径包含嵌套目录 + source, target = moved[0] + assert "train" in str(target) + assert "qtclass" in str(target) + + @patch("app.asset.backup.typer.echo") + @patch("shutil.move") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.exists") + def test_move_files_skip_existing( + self, mock_exists, mock_mkdir, mock_move, mock_echo + ): + """测试跳过已存在的文件""" + project_root = Path("/tmp/project") + journal_dir = project_root / "docs" / "journal" + archive_dir = project_root / "docs" / "archive" / "journal" + + mock_exists.return_value = True + + files = [ + ( + project_root / "docs" / "journal" / "work" / "2024-01-15.md", + datetime(2024, 1, 15), + "work", + ), + ] + + moved = move_files(files, archive_dir, journal_dir, project_root, dry_run=False) + + assert len(moved) == 0 + mock_move.assert_not_called() + + @patch("app.asset.backup.typer.echo") + @patch("shutil.move") + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.exists") + def test_move_files_dry_run(self, mock_exists, mock_mkdir, mock_move, mock_echo): + """测试预览模式""" + project_root = Path("/tmp/project") + journal_dir = project_root / "docs" / "journal" + archive_dir = project_root / "docs" / "archive" / "journal" + + mock_exists.return_value = False + + files = [ + ( + project_root / "docs" / "journal" / "work" / "2024-01-15.md", + datetime(2024, 1, 15), + "work", + ), + ] + + moved = move_files(files, archive_dir, journal_dir, project_root, dry_run=True) + + assert len(moved) == 1 + mock_move.assert_not_called() + mock_mkdir.assert_not_called() + + +class TestRunGitCommand: + """测试运行 git 命令""" + + @patch("app.asset.backup.subprocess.run") + @patch("app.asset.backup.typer.echo") + def test_run_git_command_success(self, mock_echo, mock_run): + """测试成功运行 git 命令""" + mock_run.return_value = MagicMock(stdout="", stderr="", returncode=0) + + result = run_git_command(["git", "status"], Path("/tmp/repo"), Path("/tmp")) + + assert result.returncode == 0 + mock_run.assert_called_once() + + @patch("app.asset.backup.subprocess.run") + @patch("app.asset.backup.typer.echo") + def test_run_git_command_failure(self, mock_echo, mock_run): + """测试 git 命令失败""" + mock_run.return_value = MagicMock( + stdout="", stderr="error message", returncode=1 + ) + + result = run_git_command(["git", "invalid"], Path("/tmp/repo"), Path("/tmp")) + + assert result.returncode == 1 + assert result.stderr == "error message" + + +class TestCheckGitStatus: + """测试检查 git 状态""" + + @patch("app.asset.backup.run_git_command") + def test_clean_git_status(self, mock_run_git): + """测试干净的 git 状态""" + mock_run_git.return_value = MagicMock(stdout="", returncode=0) + + result = check_git_status(Path("/tmp/repo"), Path("/tmp")) + + assert result is False + mock_run_git.assert_called_once() + + @patch("app.asset.backup.run_git_command") + def test_dirty_git_status(self, mock_run_git): + """测试有变更的 git 状态""" + mock_run_git.return_value = MagicMock( + stdout=" M file.txt\n?? new_file.txt", returncode=0 + ) + + result = check_git_status(Path("/tmp/repo"), Path("/tmp")) + + assert result is True + + +class TestCommitAndPush: + """测试提交和推送""" + + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_commit_no_changes(self, mock_echo, mock_check_status): + """测试没有变更时不提交""" + mock_check_status.return_value = False + + result = commit_and_push(Path("/tmp/repo"), "test commit", Path("/tmp")) + + assert result is False + mock_echo.assert_called() + + @patch("app.asset.backup.run_git_command") + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_commit_success(self, mock_echo, mock_check_status, mock_run_git): + """测试成功提交""" + mock_check_status.return_value = True + mock_run_git.return_value = MagicMock(returncode=0) + + result = commit_and_push( + Path("/tmp/repo"), "test commit", Path("/tmp"), push=False + ) + + assert result is True + assert mock_run_git.call_count == 2 # add 和 commit + + @patch("app.asset.backup.run_git_command") + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_commit_with_push(self, mock_echo, mock_check_status, mock_run_git): + """测试提交并推送""" + mock_check_status.return_value = True + mock_run_git.return_value = MagicMock(returncode=0) + + result = commit_and_push( + Path("/tmp/repo"), "test commit", Path("/tmp"), push=True + ) + + assert result is True + assert mock_run_git.call_count == 3 # add, commit,push + + @patch("app.asset.backup.run_git_command") + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_commit_failure(self, mock_echo, mock_check_status, mock_run_git): + """测试提交失败""" + mock_check_status.return_value = True + mock_run_git.side_effect = [ + MagicMock(returncode=0), # git add + MagicMock(returncode=1, stderr="commit failed"), # git commit + ] + + result = commit_and_push(Path("/tmp/repo"), "test commit", Path("/tmp")) + + assert result is False + + +class TestUpdateSubmoduleInMainRepo: + """测试更新主仓库子模块引用""" + + @patch("app.asset.backup.run_git_command") + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_update_submodule_no_changes( + self, mock_echo, mock_check_status, mock_run_git + ): + """测试子模块无变更""" + mock_check_status.return_value = False + + update_submodule_in_main_repo("journal", "update message", Path("/tmp")) + + mock_run_git.assert_called_once() # 只调用 git add + + @patch("app.asset.backup.run_git_command") + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_update_submodule_success(self, mock_echo, mock_check_status, mock_run_git): + """测试成功更新子模块""" + mock_check_status.return_value = True + mock_run_git.return_value = MagicMock(returncode=0) + + update_submodule_in_main_repo("journal", "update message", Path("/tmp")) + + assert mock_run_git.call_count >= 2 # add, commit + + @patch("app.asset.backup.run_git_command") + @patch("app.asset.backup.check_git_status") + @patch("app.asset.backup.typer.echo") + def test_update_submodule_with_push( + self, mock_echo, mock_check_status, mock_run_git + ): + """测试更新子模块并推送""" + mock_check_status.return_value = True + mock_run_git.return_value = MagicMock(returncode=0) + + update_submodule_in_main_repo( + "journal", "update message", Path("/tmp"), push=True + ) + + assert mock_run_git.call_count >= 3 # add, commit,push + + +class TestGetProjectRoot: + """测试获取项目根目录""" + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + def test_found_project_root(self, mock_exists, mock_cwd): + """测试找到项目根目录""" + mock_cwd.return_value = Path("/tmp/project/subdir") + + def exists_side_effect(path): + if str(path).endswith("docs/journal"): + return True + if str(path).endswith("docs/archive/journal"): + return True + return False + + mock_exists.side_effect = exists_side_effect + + # 由于 mock 限制,直接测试返回值逻辑 + # 实际测试需要真实的目录结构 + pass + + def test_get_project_root_current_dir(self): + """测试获取当前目录作为项目根""" + # 这是一个集成测试,依赖真实环境 + # 在测试环境中可能返回当前工作目录 + result = get_project_root() + assert isinstance(result, Path) + + +class TestBackupResult: + """测试 BackupResult 数据类""" + + def test_backup_result_default(self): + """测试默认值""" + result = BackupResult(success=True, message="success") + assert result.success is True + assert result.message == "success" + assert result.moved_count == 0 + assert result.dry_run is False + + def test_backup_result_custom_values(self): + """测试自定义值""" + result = BackupResult(success=True, message="done", moved_count=5, dry_run=True) + assert result.success is True + assert result.message == "done" + assert result.moved_count == 5 + assert result.dry_run is True diff --git a/src/cli/uv.lock b/src/cli/uv.lock new file mode 100644 index 00000000..b4173412 --- /dev/null +++ b/src/cli/uv.lock @@ -0,0 +1,179 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qtadmin-cli" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "typer", specifier = ">=0.12.0" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] diff --git a/src/provider/.gitignore b/src/provider/.gitignore new file mode 100644 index 00000000..bd1d7bb9 --- /dev/null +++ b/src/provider/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.env +.venv +*.db + +.python-version diff --git a/src/provider/README.md b/src/provider/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/provider/app/__init__.py b/src/provider/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/provider/app/__main__.py b/src/provider/app/__main__.py new file mode 100644 index 00000000..b1e9a0bf --- /dev/null +++ b/src/provider/app/__main__.py @@ -0,0 +1,200 @@ +import asyncio +import json +import os +import subprocess +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +import httpx +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.human.database import SessionLocal, init_db +from app.human.models.processed_mail import ProcessedMail +from app.human.routers import ( + ai_config, applications, candidates, export, ingest, materials, messages, + pipeline, pool, queue, recruitments, +) + +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_DATA_DIR = os.environ.get("QTADMIN_DATA_DIR", os.path.join(_PROJECT_ROOT, "data")) +_ATTACHMENT_DIR = os.path.join(_DATA_DIR, "attachments") + + +def seed_data_if_empty(): + """Check if DB is empty and seed demo data if so.""" + db = SessionLocal() + try: + from app.human.models.recruitment import Recruitment + exists = db.query(Recruitment).first() + if not exists: + from app.human.seed import seed_data + seed_data(db) + finally: + db.close() + + +def _download_attachment(message_id: str, attachment: dict, mailbox: str) -> str | None: + """Download attachment via lark-cli download_url, return local path.""" + att_id = attachment.get("message_attachment_id") + if not att_id: + return None + + cmd = [ + "lark-cli", "mail", "user_mailbox.message.attachments", "download_url", + "--params", json.dumps({ + "user_mailbox_id": mailbox or "me", + "message_id": message_id, + "attachment_ids": [att_id], + }), + "--format", "json", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + result.check_returncode() + resp = json.loads(result.stdout) + urls = resp.get("data", {}).get("download_urls", []) + if not urls: + return None + download_url = urls[0].get("download_url", "") + if not download_url: + return None + except Exception: + return None + + storage_dir = os.path.join(_ATTACHMENT_DIR, message_id) + os.makedirs(storage_dir, exist_ok=True) + file_path = os.path.join(storage_dir, attachment["filename"]) + + try: + r = httpx.get(download_url, timeout=60, follow_redirects=True) + r.raise_for_status() + with open(file_path, "wb") as f: + f.write(r.content) + attachment["size"] = len(r.content) + return file_path + except Exception: + return None + + +def _fetch_mail(mailbox: str) -> list[dict]: + from feishu_integration.mail_reader import fetch_and_classify, fetch_single_email + items = fetch_and_classify(mailbox=mailbox) + for item in items: + try: + detail = fetch_single_email(item["message_id"], mailbox=mailbox) + item["body"] = detail.get("body", "") + item["body_text"] = detail.get("body_plain_text", "") + item["recipient_email"] = detail.get("to", "") + attachments = [] + for a in detail.get("attachments", []): + att = { + "filename": a.get("filename", ""), + "size": a.get("size", 0), + "mime_type": a.get("content_type", ""), + "message_attachment_id": a.get("message_attachment_id") or a.get("id", ""), + } + if att["mime_type"] in ("application/pdf",) or att["filename"].endswith(".pdf"): + storage_path = _download_attachment(item["message_id"], att, mailbox) + if storage_path: + att["storage_path"] = storage_path + attachments.append(att) + item["attachments"] = attachments + except Exception: + pass + return items + + +async def _poll_mailbox(): + mailbox = os.environ.get("QTADMIN_MAILBOX", "") + if not mailbox: + return + while True: + try: + items = await asyncio.to_thread(_fetch_mail, mailbox) + db = SessionLocal() + try: + known = {row[0] for row in db.query(ProcessedMail.message_id).all()} + new_items = [it for it in items if it["message_id"] not in known] + for item in new_items: + db.add(ProcessedMail(message_id=item["message_id"])) + db.commit() + finally: + db.close() + if new_items: + payload = { + "source": "feishu_api", + "items": [ + { + "message_id": item["message_id"], + "subject": item["subject"], + "sender_name": item.get("sender_name", ""), + "sender_email": item["sender_email"], + "recipient_email": item.get("recipient_email", ""), + "suggested_status": item.get("suggested_status"), + "confidence": item.get("confidence", "low"), + "body": item.get("body"), + "body_text": item.get("body_text"), + "attachments": item.get("attachments"), + } + for item in new_items + ], + } + async with httpx.AsyncClient() as client: + resp = await client.post( + "http://localhost:8080/ingest", + json=payload, + timeout=30, + ) + resp.raise_for_status() + except Exception: + pass + await asyncio.sleep(300) + + +_poll_task: asyncio.Task | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + os.makedirs(_ATTACHMENT_DIR, exist_ok=True) + init_db() + seed_data_if_empty() + global _poll_task + _poll_task = asyncio.create_task(_poll_mailbox()) + yield + if _poll_task: + _poll_task.cancel() + + +app = FastAPI(title="qtadmin API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(ai_config.router) +app.include_router(export.router) +app.include_router(materials.router) +app.include_router(messages.router) +app.include_router(ingest.router) +app.include_router(queue.router) +app.include_router(pipeline.router) +app.include_router(pool.router) +app.include_router(recruitments.router) +app.include_router(candidates.router) +app.include_router(applications.router) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/src/provider/app/human/__init__.py b/src/provider/app/human/__init__.py new file mode 100644 index 00000000..05a5ccdd --- /dev/null +++ b/src/provider/app/human/__init__.py @@ -0,0 +1 @@ +"""Human resources module — recruitment pipeline management.""" diff --git a/src/provider/app/human/database.py b/src/provider/app/human/database.py new file mode 100644 index 00000000..cb3d2583 --- /dev/null +++ b/src/provider/app/human/database.py @@ -0,0 +1,29 @@ +"""Database setup for HR module.""" +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +DB_PATH = "hr.db" +DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def init_db() -> None: + """Create all HR tables.""" + import app.human.models # noqa: F401 + Base.metadata.create_all(bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/provider/app/human/models/__init__.py b/src/provider/app/human/models/__init__.py new file mode 100644 index 00000000..a4eae20d --- /dev/null +++ b/src/provider/app/human/models/__init__.py @@ -0,0 +1,16 @@ +"""HR models.""" +from app.human.models.talent import Talent, TalentStatus +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.processed_mail import ProcessedMail + +__all__ = [ + "Talent", "TalentStatus", + "Recruitment", + "Candidate", + "Application", + "PendingQueueItem", + "ProcessedMail", +] diff --git a/src/provider/app/human/models/ai_config.py b/src/provider/app/human/models/ai_config.py new file mode 100644 index 00000000..afe83b4c --- /dev/null +++ b/src/provider/app/human/models/ai_config.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class AIConfig(Base): + __tablename__ = "ai_configs" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + provider: Mapped[str] = mapped_column(String(50), default="openai") + base_url: Mapped[str] = mapped_column(String(500), default="") + api_key_encrypted: Mapped[str] = mapped_column(String(500), default="") + model: Mapped[str] = mapped_column(String(100), default="") + prompt_template: Mapped[str] = mapped_column(Text, default="") + timeout_seconds: Mapped[int] = mapped_column(Integer, default=30) + retry_times: Mapped[int] = mapped_column(Integer, default=2) + + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/models/application.py b/src/provider/app/human/models/application.py new file mode 100644 index 00000000..f768024e --- /dev/null +++ b/src/provider/app/human/models/application.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.human.database import Base +from app.human.models.talent import TalentStatus + + +class Application(Base): + __tablename__ = "applications" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + candidate_id: Mapped[int] = mapped_column(ForeignKey("candidates.id"), index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + source_queue_item_id: Mapped[int | None] = mapped_column(ForeignKey("pending_queue.id"), nullable=True, index=True) + + last_message_id: Mapped[int | None] = mapped_column( + ForeignKey("mail_messages.id"), nullable=True, index=True + ) + last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + candidate: Mapped["Candidate"] = relationship("Candidate") + talent: Mapped["Talent | None"] = relationship("Talent", back_populates="application", uselist=False) + + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None) + source: Mapped[str] = mapped_column(String(50), default="manual_seed") + + pooled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/models/candidate.py b/src/provider/app/human/models/candidate.py new file mode 100644 index 00000000..ab20859f --- /dev/null +++ b/src/provider/app/human/models/candidate.py @@ -0,0 +1,18 @@ +"""Candidate model — person entity, not tied to a specific recruitment.""" +from datetime import datetime + +from sqlalchemy import DateTime, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class Candidate(Base): + __tablename__ = "candidates" + __table_args__ = (UniqueConstraint("email", name="uq_candidates_email"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/correction_log.py b/src/provider/app/human/models/correction_log.py new file mode 100644 index 00000000..e24df349 --- /dev/null +++ b/src/provider/app/human/models/correction_log.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class CorrectionLog(Base): + __tablename__ = "correction_logs" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + queue_item_id: Mapped[int] = mapped_column(ForeignKey("pending_queue.id"), index=True) + application_id: Mapped[int | None] = mapped_column(ForeignKey("applications.id"), nullable=True, index=True) + field_name: Mapped[str] = mapped_column(String(50)) + original_value: Mapped[str | None] = mapped_column(String(500), nullable=True) + corrected_value: Mapped[str | None] = mapped_column(String(500), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/mail_message.py b/src/provider/app/human/models/mail_message.py new file mode 100644 index 00000000..709f469a --- /dev/null +++ b/src/provider/app/human/models/mail_message.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class MailMessage(Base): + __tablename__ = "mail_messages" + __table_args__ = (UniqueConstraint("message_id", name="uq_mail_messages_message_id"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + source_queue_item_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("pending_queue.id"), nullable=True, index=True + ) + candidate_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("candidates.id"), nullable=True, index=True + ) + application_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("applications.id"), nullable=True, index=True + ) + message_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + platform_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + + sender_email: Mapped[str] = mapped_column(String(255)) + recipient_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + subject: Mapped[str] = mapped_column(String(500)) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + attachments_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + stage_snapshot: Mapped[str | None] = mapped_column(String(50), nullable=True) + direction: Mapped[str] = mapped_column(String(20), default="inbound") + + send_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + lease_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + leased_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + retry_count: Mapped[int] = mapped_column(Integer, default=0) + last_retry_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + occurred_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/material.py b/src/provider/app/human/models/material.py new file mode 100644 index 00000000..ef636a72 --- /dev/null +++ b/src/provider/app/human/models/material.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class MaterialArtifact(Base): + __tablename__ = "material_artifacts" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + queue_item_id: Mapped[int] = mapped_column( + Integer, ForeignKey("pending_queue.id"), index=True, nullable=True + ) + candidate_id: Mapped[int] = mapped_column( + Integer, ForeignKey("candidates.id"), index=True, nullable=True + ) + artifact_type: Mapped[str] = mapped_column(String(50)) + content_json: Mapped[str | None] = mapped_column(Text, nullable=True) + file_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/pending_queue.py b/src/provider/app/human/models/pending_queue.py new file mode 100644 index 00000000..0e06fe5a --- /dev/null +++ b/src/provider/app/human/models/pending_queue.py @@ -0,0 +1,29 @@ +"""Pending queue — emails awaiting HR confirmation before entering pipeline.""" +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class PendingQueueItem(Base): + __tablename__ = "pending_queue" + __table_args__ = (UniqueConstraint("message_id"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + source: Mapped[str] = mapped_column(String(50), default="feishu_api") + message_id: Mapped[str] = mapped_column(String(255)) + subject: Mapped[str] = mapped_column(String(500)) + sender_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + sender_email: Mapped[str] = mapped_column(String(255)) + suggested_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + confidence: Mapped[str] = mapped_column(String(20), default="low") + suggested_recruitment_title: Mapped[str | None] = mapped_column(String(255), nullable=True) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + attachments_json: Mapped[str | None] = mapped_column(Text, nullable=True) + hr_status: Mapped[str] = mapped_column(String(20), default="pending") + hr_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/models/processed_mail.py b/src/provider/app/human/models/processed_mail.py new file mode 100644 index 00000000..50eb7cbf --- /dev/null +++ b/src/provider/app/human/models/processed_mail.py @@ -0,0 +1,12 @@ +"""Processed mail tracking for Feishu mailbox polling dedup.""" +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, String + +from app.human.database import Base + + +class ProcessedMail(Base): + __tablename__ = "processed_mails" + message_id: str = Column(String(255), primary_key=True) + processed_at: datetime = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/src/provider/app/human/models/recruitment.py b/src/provider/app/human/models/recruitment.py new file mode 100644 index 00000000..cc41e93c --- /dev/null +++ b/src/provider/app/human/models/recruitment.py @@ -0,0 +1,14 @@ +"""Recruitment model — job posting entity.""" +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class Recruitment(Base): + __tablename__ = "recruitments" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/talent.py b/src/provider/app/human/models/talent.py new file mode 100644 index 00000000..9747d6ed --- /dev/null +++ b/src/provider/app/human/models/talent.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import enum +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.human.database import Base + + +class TalentStatus(str, enum.Enum): + NEW = "new" + CONTACTED = "contacted" + EXAM_SENT = "exam_sent" + EXAM_RECEIVED = "exam_received" + EVALUATING = "evaluating" + INTERVIEW = "interview" + OFFER = "offer" + CLOSED = "closed" + + +ALLOWED_STATUSES_FOR_SUB_STAGE = { + TalentStatus.CONTACTED, + TalentStatus.EXAM_SENT, + TalentStatus.EVALUATING, + TalentStatus.INTERVIEW, + TalentStatus.OFFER, +} + +STATUS_TRANSITIONS = { + TalentStatus.NEW: [TalentStatus.CONTACTED, TalentStatus.CLOSED], + TalentStatus.CONTACTED: [TalentStatus.EXAM_SENT, TalentStatus.CLOSED], + TalentStatus.EXAM_SENT: [TalentStatus.EXAM_RECEIVED, TalentStatus.CLOSED], + TalentStatus.EXAM_RECEIVED: [TalentStatus.EVALUATING, TalentStatus.CLOSED], + TalentStatus.EVALUATING: [TalentStatus.INTERVIEW, TalentStatus.EXAM_SENT, TalentStatus.CLOSED], + TalentStatus.INTERVIEW: [TalentStatus.OFFER, TalentStatus.CLOSED], + TalentStatus.OFFER: [TalentStatus.CLOSED], + TalentStatus.CLOSED: [], +} + + +class Talent(Base): + __tablename__ = "talents" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + application_id: Mapped[int | None] = mapped_column(ForeignKey("applications.id"), nullable=True, index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None) + + application: Mapped[Application | None] = relationship("Application", back_populates="talent") + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/routers/__init__.py b/src/provider/app/human/routers/__init__.py new file mode 100644 index 00000000..3eca8e4d --- /dev/null +++ b/src/provider/app/human/routers/__init__.py @@ -0,0 +1 @@ +"""HR routers.""" diff --git a/src/provider/app/human/routers/ai_config.py b/src/provider/app/human/routers/ai_config.py new file mode 100644 index 00000000..d00908b7 --- /dev/null +++ b/src/provider/app/human/routers/ai_config.py @@ -0,0 +1,108 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.ai_config import AIConfig + +router = APIRouter(prefix="/ai", tags=["ai"]) + + +class AIConfigRead(BaseModel): + enabled: bool = False + provider: str = "openai" + base_url: str = "" + api_key: str = "" + model: str = "" + prompt_template: str = "" + timeout_seconds: int = 30 + retry_times: int = 2 + + model_config = {"from_attributes": True} + + +class AIConfigUpdate(BaseModel): + enabled: bool | None = None + provider: str | None = None + base_url: str | None = None + api_key: str | None = None + model: str | None = None + prompt_template: str | None = None + timeout_seconds: int | None = None + retry_times: int | None = None + + +class AIConfigTestResult(BaseModel): + success: bool + message: str + + +def _mask_api_key(key: str) -> str: + if len(key) <= 4: + return "****" + return key[:4] + "****" + + +def _get_or_create_config(db: Session) -> AIConfig: + cfg = db.query(AIConfig).first() + if not cfg: + cfg = AIConfig() + db.add(cfg) + db.flush() + return cfg + + +@router.get("/config", response_model=AIConfigRead) +def get_ai_config(db: Session = Depends(get_db)): + cfg = _get_or_create_config(db) + data = AIConfigRead.model_validate(cfg) + if cfg.api_key_encrypted: + data.api_key = _mask_api_key(cfg.api_key_encrypted) + return data + + +@router.patch("/config", response_model=AIConfigRead) +def update_ai_config(body: AIConfigUpdate, db: Session = Depends(get_db)): + cfg = _get_or_create_config(db) + updates = body.model_dump(exclude_unset=True) + if "api_key" in updates: + cfg.api_key_encrypted = updates.pop("api_key") + for field, val in updates.items(): + setattr(cfg, field, val) + db.commit() + db.refresh(cfg) + + data = AIConfigRead.model_validate(cfg) + if cfg.api_key_encrypted: + data.api_key = _mask_api_key(cfg.api_key_encrypted) + return data + + +@router.post("/test", response_model=AIConfigTestResult) +def test_ai_config(db: Session = Depends(get_db)): + import httpx + + cfg = db.query(AIConfig).first() + if not cfg or not cfg.enabled: + return AIConfigTestResult(success=False, message="AI 未启用") + if not cfg.api_key_encrypted: + return AIConfigTestResult(success=False, message="API Key 未配置") + + url = (cfg.base_url or "https://api.openai.com/v1").rstrip("/") + "/chat/completions" + headers = {"Authorization": f"Bearer {cfg.api_key_encrypted}", "Content-Type": "application/json"} + payload = { + "model": cfg.model or "gpt-4o-mini", + "messages": [{"role": "user", "content": "回复 OK 表示连接正常"}], + "max_tokens": 10, + } + + try: + resp = httpx.post(url, headers=headers, json=payload, timeout=cfg.timeout_seconds or 30) + resp.raise_for_status() + return AIConfigTestResult(success=True, message="AI 连接成功") + except httpx.TimeoutException: + return AIConfigTestResult(success=False, message="连接超时") + except httpx.HTTPStatusError as e: + return AIConfigTestResult(success=False, message=f"HTTP {e.response.status_code}: {e.response.text[:200]}") + except Exception as e: + return AIConfigTestResult(success=False, message=f"连接失败: {str(e)[:200]}") diff --git a/src/provider/app/human/routers/applications.py b/src/provider/app/human/routers/applications.py new file mode 100644 index 00000000..16d28e80 --- /dev/null +++ b/src/provider/app/human/routers/applications.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.application import Application +from app.human.models.talent import TalentStatus +from app.human.schemas.application import ApplicationRead, UnpoolRequest +from app.human.services.pool import pool_application, unpool_application + +router = APIRouter(prefix="/applications", tags=["human"]) + + +@router.get("", response_model=list[ApplicationRead]) +def list_applications( + status: TalentStatus | None = None, + candidate_id: int | None = Query(default=None, ge=1), + recruitment_id: int | None = Query(default=None, ge=1), + pooled: bool | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + qb = db.query(Application) + if status: + qb = qb.filter(Application.status == status) + if candidate_id: + qb = qb.filter(Application.candidate_id == candidate_id) + if recruitment_id: + qb = qb.filter(Application.recruitment_id == recruitment_id) + if pooled is True: + qb = qb.filter(Application.pooled_at.isnot(None)) + elif pooled is False: + qb = qb.filter(Application.pooled_at.is_(None)) + return qb.order_by(Application.updated_at.desc()).offset(skip).limit(limit).all() + + +@router.post("/{application_id}/pool", response_model=ApplicationRead) +def pool_application_endpoint(application_id: int, db: Session = Depends(get_db)): + app = pool_application(db, application_id) + if not app: + raise HTTPException(404, "Application not found") + return app + + +@router.post("/{application_id}/unpool", response_model=ApplicationRead, status_code=201) +def unpool_application_endpoint(application_id: int, body: UnpoolRequest, db: Session = Depends(get_db)): + original = db.query(Application).filter(Application.id == application_id).first() + if not original: + raise HTTPException(404, "Application not found") + if original.pooled_at is None: + raise HTTPException(400, "Application is not pooled") + new_app = unpool_application(db, application_id, body.recruitment_id) + return new_app diff --git a/src/provider/app/human/routers/candidates.py b/src/provider/app/human/routers/candidates.py new file mode 100644 index 00000000..849aa584 --- /dev/null +++ b/src/provider/app/human/routers/candidates.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.schemas.candidate import CandidateRead +from app.human.schemas.application import ApplicationRead + +router = APIRouter(prefix="/candidates", tags=["human"]) + + +@router.get("", response_model=list[CandidateRead]) +def list_candidates( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + return db.query(Candidate).order_by(Candidate.created_at.desc()).offset(skip).limit(limit).all() + + +@router.get("/{candidate_id}/applications", response_model=list[ApplicationRead]) +def get_candidate_applications(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + return db.query(Application).filter(Application.candidate_id == candidate_id).all() diff --git a/src/provider/app/human/routers/export.py b/src/provider/app/human/routers/export.py new file mode 100644 index 00000000..523273d5 --- /dev/null +++ b/src/provider/app/human/routers/export.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.schemas.export import TrainingPairResponse +from app.human.services.export import count_training_pairs, get_training_pairs + +router = APIRouter(prefix="/export", tags=["export"]) + + +@router.get("/training-pairs", response_model=TrainingPairResponse) +def list_training_pairs( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + items = get_training_pairs(db, skip=skip, limit=limit) + total = count_training_pairs(db) + return TrainingPairResponse(items=items, total=total) diff --git a/src/provider/app/human/routers/ingest.py b/src/provider/app/human/routers/ingest.py new file mode 100644 index 00000000..dadc91ee --- /dev/null +++ b/src/provider/app/human/routers/ingest.py @@ -0,0 +1,114 @@ +"""Ingest endpoint — receive raw emails from CLI, classify server-side, queue for HR review.""" +import json +import logging + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.pending_queue import PendingQueueItem +from app.human.services.classifier import classify + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ingest", tags=["human"]) + + +class IngestAttachment(BaseModel): + filename: str + size: int + + +class IngestItem(BaseModel): + message_id: str + subject: str + sender_name: str | None = None + sender_email: str + suggested_status: str | None = None + confidence: str = "low" + suggested_recruitment_title: str | None = None + body: str | None = None + body_text: str | None = None + attachments: list[IngestAttachment] | None = None + + +class IngestRequest(BaseModel): + source: str = "feishu_api" + batch_id: str | None = None + items: list[IngestItem] + + +class IngestItemResult(BaseModel): + message_id: str + queue_id: int | None = None + action: str + + +class IngestResponse(BaseModel): + batch_id: str | None = None + queued: int = 0 + skipped: int = 0 + errors: list[str] = [] + items: list[IngestItemResult] + + +@router.post("", response_model=IngestResponse, status_code=201) +def ingest_items(body: IngestRequest, db: Session = Depends(get_db)): + existing = { + row[0] + for row in db.query(PendingQueueItem.message_id) + .filter(PendingQueueItem.message_id.in_([i.message_id for i in body.items])) + .all() + } + + queued = 0 + skipped = 0 + results: list[IngestItemResult] = [] + errors: list[str] = [] + + for item in body.items: + if item.message_id in existing: + results.append(IngestItemResult(message_id=item.message_id, action="skipped")) + skipped += 1 + continue + + attachments_json = None + if item.attachments: + attachments_json = json.dumps([a.model_dump() for a in item.attachments], ensure_ascii=False) + + # Run server-side classification + classification = classify( + subject=item.subject, + body_text=item.body_text, + sender_name=item.sender_name, + sender_email=item.sender_email, + db=db, + ) + + qi = PendingQueueItem( + source=body.source, + message_id=item.message_id, + subject=item.subject, + sender_name=item.sender_name, + sender_email=item.sender_email, + body=item.body, + body_text=item.body_text, + suggested_status=classification.suggested_status, + confidence=classification.confidence, + suggested_recruitment_title=item.suggested_recruitment_title, + attachments_json=attachments_json, + ) + db.add(qi) + db.flush() + results.append(IngestItemResult(message_id=item.message_id, queue_id=qi.id, action="queued")) + queued += 1 + + db.commit() + return IngestResponse( + batch_id=body.batch_id, + queued=queued, + skipped=skipped, + errors=errors, + items=results, + ) diff --git a/src/provider/app/human/routers/materials.py b/src/provider/app/human/routers/materials.py new file mode 100644 index 00000000..44eaab9b --- /dev/null +++ b/src/provider/app/human/routers/materials.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.services.material_service import ( + get_artifacts_by_candidate, + get_artifacts_by_queue, +) + +router = APIRouter(prefix="/materials", tags=["materials"]) + + +class ArtifactItem(BaseModel): + id: int + queue_item_id: int | None + candidate_id: int | None + artifact_type: str + content_json: str | None = None + file_path: str | None = None + created_at: str = "" + + model_config = {"from_attributes": True} + + +class ArtifactListResponse(BaseModel): + items: list[ArtifactItem] + + +@router.get("/by-queue/{queue_id}", response_model=ArtifactListResponse) +def list_by_queue(queue_id: int, db: Session = Depends(get_db)): + items = get_artifacts_by_queue(db, queue_id) + return ArtifactListResponse( + items=[ArtifactItem( + id=a.id, queue_item_id=a.queue_item_id, candidate_id=a.candidate_id, + artifact_type=a.artifact_type, content_json=a.content_json, + file_path=a.file_path, created_at=str(a.created_at), + ) for a in items] + ) + + +@router.get("/by-candidate/{candidate_id}", response_model=ArtifactListResponse) +def list_by_candidate(candidate_id: int, db: Session = Depends(get_db)): + items = get_artifacts_by_candidate(db, candidate_id) + return ArtifactListResponse( + items=[ArtifactItem( + id=a.id, queue_item_id=a.queue_item_id, candidate_id=a.candidate_id, + artifact_type=a.artifact_type, content_json=a.content_json, + file_path=a.file_path, created_at=str(a.created_at), + ) for a in items] + ) diff --git a/src/provider/app/human/routers/messages.py b/src/provider/app/human/routers/messages.py new file mode 100644 index 00000000..e2fbadd0 --- /dev/null +++ b/src/provider/app/human/routers/messages.py @@ -0,0 +1,338 @@ +import os +from datetime import datetime, timedelta +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.application import Application +from app.human.models.candidate import Candidate +from app.human.models.correction_log import CorrectionLog +from app.human.models.mail_message import MailMessage +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.recruitment import Recruitment +from app.human.schemas.messages import ( + ClaimOutboxResponse, + DeadLetterItem, + MailMessageRead, + OutboxCountResponse, + OutboxMessageDetail, + ReplyRequest, + ReplyResponse, + RequeueResponse, + SendStatusUpdate, + TimelineItem, +) + +router = APIRouter(tags=["messages"]) + + +# ── Batch C: Candidate messages ── + +@router.get("/candidates/{candidate_id}/messages", response_model=list[MailMessageRead]) +def list_candidate_messages(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + + msgs = ( + db.query(MailMessage) + .filter(MailMessage.candidate_id == candidate_id) + .order_by(MailMessage.occurred_at.desc()) + .all() + ) + return [ + MailMessageRead( + id=m.id, candidate_id=m.candidate_id, application_id=m.application_id, + message_id=m.message_id, sender_email=m.sender_email, + recipient_email=m.recipient_email, subject=m.subject, + body=m.body, body_text=m.body_text, attachments_json=m.attachments_json, + stage_snapshot=m.stage_snapshot, direction=m.direction, + send_status=m.send_status, occurred_at=str(m.occurred_at), + created_at=str(m.created_at), + ) + for m in msgs + ] + + +@router.get("/candidates/{candidate_id}/timeline", response_model=list[TimelineItem]) +def list_candidate_timeline(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + + items: list[TimelineItem] = [] + + # Mail messages + msgs = ( + db.query(MailMessage) + .filter(MailMessage.candidate_id == candidate_id) + .order_by(MailMessage.occurred_at.desc()) + .all() + ) + for m in msgs: + direction_label = "收信" if m.direction == "inbound" else "发信" + items.append(TimelineItem( + type="message", + timestamp=str(m.occurred_at), + description=f"{direction_label}: {m.subject}", + detail={ + "id": m.id, + "direction": m.direction, + "subject": m.subject, + "stage_snapshot": m.stage_snapshot, + "send_status": m.send_status, + }, + )) + + # Correction logs (stage changes) + apps = db.query(Application).filter(Application.candidate_id == candidate_id).all() + app_ids = [a.id for a in apps] + if app_ids: + logs = ( + db.query(CorrectionLog) + .filter( + CorrectionLog.application_id.in_(app_ids), + CorrectionLog.field_name == "status", + ) + .order_by(CorrectionLog.created_at.desc()) + .all() + ) + for log in logs: + items.append(TimelineItem( + type="stage_change", + timestamp=str(log.created_at), + description=f"HR 调整阶段: {log.original_value or '空'} → {log.corrected_value}", + detail={ + "queue_item_id": log.queue_item_id, + "original_value": log.original_value, + "corrected_value": log.corrected_value, + }, + )) + + items.sort(key=lambda x: x.timestamp, reverse=True) + return items + + +@router.post("/candidates/{candidate_id}/reply", response_model=ReplyResponse, status_code=201) +def create_reply(candidate_id: int, body: ReplyRequest, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + + app = db.query(Application).filter(Application.id == body.application_id).first() + if not app or app.candidate_id != candidate_id: + raise HTTPException(400, "Application not found for this candidate") + + # Look up original inbound message to determine system mailbox (sender) + original_msg = ( + db.query(MailMessage) + .filter( + MailMessage.application_id == body.application_id, + MailMessage.direction == "inbound", + ) + .order_by(MailMessage.occurred_at.asc()) + .first() + ) + + _system_mailbox = os.environ.get("QTADMIN_MAILBOX", "") + sender_email = ( + body.sender_email + or (original_msg.recipient_email if original_msg else None) + or _system_mailbox + or "" + ) + + mm = MailMessage( + candidate_id=candidate_id, + application_id=body.application_id, + sender_email=sender_email, + recipient_email=body.recipient_email or c.email, + subject=body.subject, + body=body.body, + body_text=body.body_text, + stage_snapshot=app.status.value, + direction="outbound", + send_status="pending", + occurred_at=func.now(), + ) + db.add(mm) + db.commit() + db.refresh(mm) + + return ReplyResponse( + id=mm.id, + subject=mm.subject, + send_status="pending", + created_at=str(mm.created_at), + ) + + +# ── Batch C: Outbox ── + +_OUTBOX_CLAIM_LIMIT = 10 +_OUTBOX_TIMEOUT_MINUTES = 5 +_OUTBOX_MAX_RETRIES = 5 + + +@router.get("/messages/outbox", response_model=OutboxCountResponse) +def outbox_count( + db: Session = Depends(get_db), + status: str | None = Query(None, description="Filter by send_status"), +): + filter_statuses = [status] if status else ["pending", "sending"] + count = ( + db.query(func.count(MailMessage.id)) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status.in_(filter_statuses), + ) + .scalar() + ) + return OutboxCountResponse(count=count or 0) + + +@router.post("/messages/outbox/claim", response_model=ClaimOutboxResponse) +def claim_outbox(db: Session = Depends(get_db)): + now = datetime.now() + + # Pending messages — apply exponential backoff for retries + pending_raw = ( + db.query(MailMessage) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status == "pending", + ) + .order_by(MailMessage.created_at.asc()) + .all() + ) + pending = [] + for m in pending_raw: + if m.retry_count == 0 or m.last_retry_at is None: + pending.append(m) + else: + backoff_minutes = 2 ** (m.retry_count - 1) + if m.last_retry_at + timedelta(minutes=backoff_minutes) <= now: + pending.append(m) + pending = pending[:_OUTBOX_CLAIM_LIMIT] + + expired = ( + db.query(MailMessage) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status == "sending", + MailMessage.leased_at < (now - timedelta(minutes=_OUTBOX_TIMEOUT_MINUTES)), + ) + .limit(_OUTBOX_CLAIM_LIMIT) + .all() + ) + + to_claim = pending + expired + for m in to_claim: + m.send_status = "sending" + m.lease_id = str(uuid4()) + m.leased_at = now + + db.commit() + + claimed = [ + { + "id": m.id, + "lease_id": m.lease_id, + "subject": m.subject, + "recipient_email": m.recipient_email, + } + for m in to_claim + ] + return ClaimOutboxResponse(claimed=claimed) + + +@router.get("/messages/outbox/dead", response_model=list[DeadLetterItem]) +def list_dead_letters(db: Session = Depends(get_db)): + items = ( + db.query(MailMessage) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status == "failed", + MailMessage.retry_count >= _OUTBOX_MAX_RETRIES, + ) + .order_by(MailMessage.created_at.desc()) + .all() + ) + return [ + DeadLetterItem( + id=m.id, application_id=m.application_id, candidate_id=m.candidate_id, + subject=m.subject, recipient_email=m.recipient_email, + failure_reason=m.failure_reason, retry_count=m.retry_count or 0, + last_retry_at=str(m.last_retry_at) if m.last_retry_at else None, + created_at=str(m.created_at), + ) + for m in items + ] + + +@router.post("/messages/outbox/{message_id}/requeue", response_model=RequeueResponse) +def requeue_dead_letter(message_id: int, db: Session = Depends(get_db)): + m = db.query(MailMessage).filter(MailMessage.id == message_id).first() + if not m: + raise HTTPException(404, "Message not found") + if m.send_status != "failed" or (m.retry_count or 0) < _OUTBOX_MAX_RETRIES: + raise HTTPException(400, "Message is not a dead letter (send_status must be 'failed' with retry_count >= 5)") + + m.send_status = "pending" + m.retry_count = 0 + m.lease_id = None + m.leased_at = None + m.last_retry_at = None + m.failure_reason = None + db.commit() + return RequeueResponse(id=m.id, send_status="pending", retry_count=0) + + +@router.get("/messages/outbox/{message_id}", response_model=OutboxMessageDetail) +def get_outbox_message(message_id: int, lease_id: str = Query(...), db: Session = Depends(get_db)): + m = db.query(MailMessage).filter(MailMessage.id == message_id).first() + if not m: + raise HTTPException(404, "Message not found") + if m.lease_id != lease_id: + raise HTTPException(403, "lease_id mismatch") + return OutboxMessageDetail( + id=m.id, lease_id=m.lease_id, subject=m.subject, + body=m.body, body_text=m.body_text, + recipient_email=m.recipient_email, attachments_json=m.attachments_json, + ) + + +# ── Batch D: Send status callback ── + +@router.patch("/messages/{message_id}/send-status") +def update_send_status(message_id: int, body: SendStatusUpdate, db: Session = Depends(get_db)): + m = db.query(MailMessage).filter(MailMessage.id == message_id).first() + if not m: + raise HTTPException(404, "Message not found") + if m.lease_id != body.lease_id: + raise HTTPException(409, "lease_id mismatch — callback rejected") + + m.send_status = body.send_status + if body.send_status == "sent": + m.sent_at = datetime.fromisoformat(body.sent_at) if body.sent_at else func.now() + m.platform_message_id = body.platform_message_id + elif body.send_status == "failed": + now = datetime.now() + m.retry_count = (m.retry_count or 0) + 1 + m.last_retry_at = now + m.failure_reason = body.failure_reason + if m.retry_count >= _OUTBOX_MAX_RETRIES: + m.send_status = "failed" # 死信:永久失败 + m.lease_id = None + m.leased_at = None + else: + # 重置为 pending,让下一轮 claim 按指数退避重新领取 + m.send_status = "pending" + m.lease_id = None + m.leased_at = None + + db.commit() + return {"ok": True} diff --git a/src/provider/app/human/routers/pipeline.py b/src/provider/app/human/routers/pipeline.py new file mode 100644 index 00000000..e66609d4 --- /dev/null +++ b/src/provider/app/human/routers/pipeline.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.services.pipeline import get_pipeline + +router = APIRouter(prefix="/pipeline", tags=["human"]) + + +@router.get("") +def pipeline(db: Session = Depends(get_db)): + return get_pipeline(db) diff --git a/src/provider/app/human/routers/pool.py b/src/provider/app/human/routers/pool.py new file mode 100644 index 00000000..f4ad861b --- /dev/null +++ b/src/provider/app/human/routers/pool.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.application import Application +from app.human.schemas.application import PoolItemRead +from app.human.services.pool import get_pooled_applications + +router = APIRouter(prefix="/pool", tags=["human"]) + + +def _pool_item_from_orm(app: Application) -> dict: + return { + "id": app.id, + "candidate_id": app.candidate_id, + "recruitment_id": app.recruitment_id, + "status": app.status, + "sub_stage": app.sub_stage, + "quality": app.quality, + "stage_results": app.stage_results, + "source": app.source, + "pooled_at": app.pooled_at, + "deactivated_at": app.deactivated_at, + "created_at": app.created_at, + "updated_at": app.updated_at, + "candidate_email": app.candidate.email, + "candidate_name": app.candidate.real_name, + } + + +@router.get("", response_model=list[PoolItemRead]) +def list_pooled_applications( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + apps = get_pooled_applications(db, skip=skip, limit=limit) + return [_pool_item_from_orm(a) for a in apps] diff --git a/src/provider/app/human/routers/queue.py b/src/provider/app/human/routers/queue.py new file mode 100644 index 00000000..c3302f3c --- /dev/null +++ b/src/provider/app/human/routers/queue.py @@ -0,0 +1,188 @@ +"""Queue management — HR confirm, ignore, and stats.""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.models.talent import Talent, TalentStatus + +router = APIRouter(prefix="/queue", tags=["human"]) + + +class ConfirmRequest(BaseModel): + action: str = "confirmed" + status: str = "contacted" + real_name: str = "" + email: str = "" + recruitment_title: str | None = None + + +class ConfirmResponse(BaseModel): + queue_id: int + action: str + talent_id: int | None = None + + +class IgnoreRequest(BaseModel): + action: str = "ignored" + + +class QueueItemRead(BaseModel): + queue_id: int + message_id: str + subject: str + sender_name: str | None = None + sender_email: str = "" + suggested_status: str | None = None + confidence: str = "low" + hr_status: str = "pending" + created_at: str = "" + + model_config = {"from_attributes": True} + + +class QueueListResponse(BaseModel): + items: list[QueueItemRead] + total: int + + +@router.get("", response_model=QueueListResponse) +def list_queue( + hr_status: str | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), +): + qb = db.query(PendingQueueItem) + if hr_status: + qb = qb.filter(PendingQueueItem.hr_status == hr_status) + total = qb.count() + items = qb.order_by(PendingQueueItem.created_at.desc()).offset(skip).limit(limit).all() + + return QueueListResponse( + items=[QueueItemRead( + queue_id=item.id, + message_id=item.message_id, + subject=item.subject, + sender_name=item.sender_name, + sender_email=item.sender_email, + suggested_status=item.suggested_status, + confidence=item.confidence, + hr_status=item.hr_status, + created_at=str(item.created_at), + ) for item in items], + total=total, + ) + + +@router.patch("/{queue_id}/confirm", response_model=ConfirmResponse) +def confirm_queue_item(queue_id: int, body: ConfirmRequest, db: Session = Depends(get_db)): + item = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not item: + raise HTTPException(404, "Queue item not found") + if item.hr_status != "pending": + raise HTTPException(400, f"Queue item is not pending (current: {item.hr_status})") + + item.hr_status = body.action + db.flush() + + recruitment = db.query(Recruitment).order_by(Recruitment.created_at.desc()).first() + if not recruitment: + recruitment = Recruitment() + db.add(recruitment) + db.flush() + + target_email = (body.email or item.sender_email or "").lower() + candidate = db.query(Candidate).filter(Candidate.email == target_email).first() + if not candidate: + candidate = Candidate( + email=target_email, + real_name=body.real_name or item.sender_name or "未知", + ) + db.add(candidate) + db.flush() + + app = Application( + candidate_id=candidate.id, + recruitment_id=recruitment.id, + source="feishu_api", + ) + db.add(app) + db.flush() + + target_status = body.status or item.suggested_status + if target_status and target_status != "new": + try: + status_order = ["new", "contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer", "closed"] + from app.human.models.talent import STATUS_TRANSITIONS + current_idx = status_order.index(app.status.value) + target_idx = status_order.index(target_status) + for s in status_order[current_idx + 1 : target_idx + 1]: + if TalentStatus(s) in STATUS_TRANSITIONS.get(app.status, []): + app.status = TalentStatus(s) + db.flush() + except (ValueError, KeyError): + pass + + talent = Talent( + recruitment_id=recruitment.id, + email=candidate.email, + real_name=candidate.real_name, + status=app.status, + ) + db.add(talent) + db.commit() + db.refresh(talent) + + return ConfirmResponse(queue_id=item.id, action=body.action, talent_id=talent.id) + + +@router.patch("/{queue_id}/ignore", response_model=ConfirmResponse) +def ignore_queue_item(queue_id: int, body: IgnoreRequest, db: Session = Depends(get_db)): + item = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not item: + raise HTTPException(404, "Queue item not found") + if item.hr_status != "pending": + raise HTTPException(400, f"Queue item is not pending (current: {item.hr_status})") + item.hr_status = "ignored" + db.commit() + return ConfirmResponse(queue_id=item.id, action="ignored") + + +@router.get("/by-email") +def get_queue_by_email(email: str, db: Session = Depends(get_db)): + qi = ( + db.query(PendingQueueItem) + .filter(PendingQueueItem.sender_email == email) + .order_by(PendingQueueItem.created_at.desc()) + .first() + ) + if not qi: + return {"found": False} + return { + "found": True, + "item": { + "queue_id": qi.id, + "message_id": qi.message_id, + "subject": qi.subject, + "sender_name": qi.sender_name, + "sender_email": qi.sender_email, + "suggested_status": qi.suggested_status, + "confidence": qi.confidence, + "hr_status": qi.hr_status, + "hr_notes": qi.hr_notes, + }, + } + + +@router.get("/stats") +def queue_stats(db: Session = Depends(get_db)): + rows = db.execute( + text("SELECT hr_status, COUNT(*) as cnt FROM pending_queue GROUP BY hr_status") + ).all() + return {row[0]: row[1] for row in rows} or {"pending": 0, "confirmed": 0, "ignored": 0} diff --git a/src/provider/app/human/routers/recruitments.py b/src/provider/app/human/routers/recruitments.py new file mode 100644 index 00000000..54e3e5e3 --- /dev/null +++ b/src/provider/app/human/routers/recruitments.py @@ -0,0 +1,180 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.schemas.talent import SubStageUpdate, TalentCreate, TalentRead, TalentTransition, TalentUpdate +from app.human.schemas.recruitment import HeadcountRead, RecruitmentRead +from app.human.services.headcount import get_headcount + +router = APIRouter(prefix="/recruitments", tags=["human"]) + + +@router.get("", response_model=list[RecruitmentRead]) +def list_recruitments( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + return db.query(Recruitment).order_by(Recruitment.created_at.desc()).offset(skip).limit(limit).all() + + +@router.get("/{recruitment_id}", response_model=RecruitmentRead) +def get_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: + raise HTTPException(404, "Recruitment not found") + return r + + +@router.post("", response_model=RecruitmentRead, status_code=201) +def create_recruitment(db: Session = Depends(get_db)): + r = Recruitment() + db.add(r) + db.commit() + db.refresh(r) + return r + + +@router.delete("/{recruitment_id}", status_code=204) +def delete_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: + raise HTTPException(404, "Recruitment not found") + db.delete(r) + db.commit() + + +@router.get("/{recruitment_id}/headcount", response_model=HeadcountRead) +def get_recruitment_headcount(recruitment_id: int, db: Session = Depends(get_db)): + _recruitment_exists(recruitment_id, db) + return get_headcount(db, recruitment_id) + + +def _recruitment_exists(recruitment_id: int, db: Session) -> None: + if not db.query(Recruitment).filter(Recruitment.id == recruitment_id).first(): + raise HTTPException(404, "Recruitment not found") + + +@router.get("/{recruitment_id}/talents", response_model=list[TalentRead]) +def list_talents( + recruitment_id: int, + status: TalentStatus | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + _recruitment_exists(recruitment_id, db) + qb = db.query(Talent).filter(Talent.recruitment_id == recruitment_id) + if status: + qb = qb.filter(Talent.status == status) + return qb.order_by(Talent.updated_at.desc()).offset(skip).limit(limit).all() + + +@router.get("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def get_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + return t + + +@router.post("/{recruitment_id}/talents", response_model=TalentRead, status_code=201) +def create_talent(recruitment_id: int, data: TalentCreate, db: Session = Depends(get_db)): + recruitment = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not recruitment: + raise HTTPException(404, "Recruitment not found") + + candidate = db.query(Candidate).filter(Candidate.email == data.email.lower()).first() + if not candidate: + candidate = Candidate(email=data.email.lower(), real_name=data.real_name) + db.add(candidate) + db.flush() + app = Application(candidate_id=candidate.id, recruitment_id=recruitment_id, source="manual_debug") + db.add(app) + db.flush() + + t = Talent(recruitment_id=recruitment_id, email=data.email, real_name=data.real_name) + db.add(t) + db.commit() + db.refresh(t) + return t + + +@router.patch("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def update_talent(recruitment_id: int, talent_id: int, data: TalentUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(t, k, v) + db.commit() + db.refresh(t) + return t + + +@router.post("/{recruitment_id}/talents/{talent_id}/transition", response_model=TalentRead) +def transition_talent(recruitment_id: int, talent_id: int, data: TalentTransition, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + + target = data.status + if target not in STATUS_TRANSITIONS.get(t.status, []): + raise HTTPException(400, f"Cannot transition from {t.status.value} to {target.value}") + + old_status = t.status + + candidate = db.query(Candidate).filter(Candidate.email == t.email).first() + if candidate: + app = (db.query(Application) + .filter(Application.candidate_id == candidate.id, + Application.recruitment_id == recruitment_id) + .order_by(Application.created_at.desc()) + .first()) + if app: + app.status = target + if target != old_status: + app.sub_stage = None + if data.sub_stage is not None and target in ALLOWED_STATUSES_FOR_SUB_STAGE: + app.sub_stage = data.sub_stage + + stage_key = old_status.value + if stage_key in ("contacted", "evaluating", "interview", "offer"): + if not (stage_key == "evaluating" and target.value == "exam_sent"): + if app.stage_results is None: + app.stage_results = {} + app.stage_results[stage_key] = "pass" if target.value != "closed" else "fail" + + t.status = app.status + t.sub_stage = app.sub_stage + t.stage_results = app.stage_results + + db.commit() + db.refresh(t) + return t + + +@router.patch("/{recruitment_id}/talents/{talent_id}/sub-stage", response_model=TalentRead) +def set_talent_sub_stage(recruitment_id: int, talent_id: int, data: SubStageUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + if t.status not in ALLOWED_STATUSES_FOR_SUB_STAGE: + raise HTTPException(400, f"Cannot set sub_stage for status {t.status.value}") + t.sub_stage = data.sub_stage + db.commit() + db.refresh(t) + return t + + +@router.delete("/{recruitment_id}/talents/{talent_id}", status_code=204) +def delete_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + db.delete(t) + db.commit() diff --git a/src/provider/app/human/schemas/__init__.py b/src/provider/app/human/schemas/__init__.py new file mode 100644 index 00000000..e550ce19 --- /dev/null +++ b/src/provider/app/human/schemas/__init__.py @@ -0,0 +1,11 @@ +from app.human.schemas.pending_queue import ( + ConfirmRequest, ConfirmResponse, IgnoreRequest, +) +from app.human.schemas.recruitment import HeadcountRead, RecruitmentRead +from app.human.schemas.talent import TalentCreate, TalentRead, TalentTransition, TalentUpdate, SubStageUpdate + +__all__ = [ + "ConfirmRequest", "ConfirmResponse", "IgnoreRequest", + "HeadcountRead", "RecruitmentRead", + "TalentCreate", "TalentRead", "TalentUpdate", "TalentTransition", "SubStageUpdate", +] diff --git a/src/provider/app/human/schemas/application.py b/src/provider/app/human/schemas/application.py new file mode 100644 index 00000000..fb3eb421 --- /dev/null +++ b/src/provider/app/human/schemas/application.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.human.models.talent import TalentStatus + + +class ApplicationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + candidate_id: int + recruitment_id: int + status: TalentStatus + sub_stage: str | None = None + quality: str = "normal" + stage_results: dict | None = None + source: str = "manual_seed" + pooled_at: datetime | None = None + deactivated_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class UnpoolRequest(BaseModel): + recruitment_id: int = Field(..., ge=1) + + +class PoolItemRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + candidate_id: int + recruitment_id: int + status: TalentStatus + sub_stage: str | None = None + quality: str = "normal" + stage_results: dict | None = None + source: str = "manual_seed" + pooled_at: datetime | None = None + deactivated_at: datetime | None = None + created_at: datetime + updated_at: datetime + candidate_email: str = "" + candidate_name: str = "" diff --git a/src/provider/app/human/schemas/candidate.py b/src/provider/app/human/schemas/candidate.py new file mode 100644 index 00000000..06d37ddf --- /dev/null +++ b/src/provider/app/human/schemas/candidate.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class CandidateRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + real_name: str + phone: str | None = None + created_at: datetime diff --git a/src/provider/app/human/schemas/export.py b/src/provider/app/human/schemas/export.py new file mode 100644 index 00000000..416a97f3 --- /dev/null +++ b/src/provider/app/human/schemas/export.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class TrainingPairItem(BaseModel): + queue_id: int + subject: str + body: str | None = None + sender_email: str + suggested_status: str | None = None + final_status: str | None = None + final_real_name: str | None = None + final_email: str | None = None + hr_action: str | None = None + corrected_fields: list[str] = [] + + +class TrainingPairResponse(BaseModel): + items: list[TrainingPairItem] + total: int diff --git a/src/provider/app/human/schemas/messages.py b/src/provider/app/human/schemas/messages.py new file mode 100644 index 00000000..d4857521 --- /dev/null +++ b/src/provider/app/human/schemas/messages.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AttachmentInfo(BaseModel): + filename: str + size: int = 0 + mime_type: str | None = None + message_attachment_id: str | None = None + storage_path: str | None = None + + +class MailMessageRead(BaseModel): + id: int + candidate_id: int | None = None + application_id: int | None = None + message_id: str | None = None + sender_email: str + recipient_email: str | None = None + subject: str + body: str | None = None + body_text: str | None = None + attachments_json: str | None = None + stage_snapshot: str | None = None + direction: str + send_status: str | None = None + occurred_at: str = "" + created_at: str = "" + + model_config = {"from_attributes": True} + + +class ReplyRequest(BaseModel): + application_id: int + subject: str + body: str | None = None + body_text: str | None = None + sender_email: str | None = None + recipient_email: str | None = None + + +class ReplyResponse(BaseModel): + id: int + direction: str = "outbound" + send_status: str = "pending" + subject: str + created_at: str = "" + + +class OutboxCountResponse(BaseModel): + count: int + + +class ClaimOutboxResponse(BaseModel): + claimed: list[dict] + + +class OutboxMessageDetail(BaseModel): + id: int + lease_id: str + subject: str + body: str | None = None + body_text: str | None = None + recipient_email: str | None = None + attachments_json: str | None = None + + +class SendStatusUpdate(BaseModel): + lease_id: str + send_status: str # "sent" | "failed" + sent_at: str | None = None + platform_message_id: str | None = None + failure_reason: str | None = None + + +class TimelineItem(BaseModel): + type: str # "message" | "stage_change" + timestamp: str + description: str + detail: dict | None = None + + +class DeadLetterItem(BaseModel): + id: int + application_id: int | None = None + candidate_id: int | None = None + subject: str + recipient_email: str | None = None + failure_reason: str | None = None + retry_count: int = 0 + last_retry_at: str | None = None + created_at: str = "" + + +class RequeueResponse(BaseModel): + id: int + send_status: str = "pending" + retry_count: int = 0 diff --git a/src/provider/app/human/schemas/pending_queue.py b/src/provider/app/human/schemas/pending_queue.py new file mode 100644 index 00000000..57b1171f --- /dev/null +++ b/src/provider/app/human/schemas/pending_queue.py @@ -0,0 +1,21 @@ +"""Shared pending queue schemas.""" + +from pydantic import BaseModel + + +class ConfirmRequest(BaseModel): + action: str = "confirmed" + status: str = "contacted" + real_name: str = "" + email: str = "" + recruitment_title: str | None = None + + +class ConfirmResponse(BaseModel): + queue_id: int + action: str + talent_id: int | None = None + + +class IgnoreRequest(BaseModel): + action: str = "ignored" diff --git a/src/provider/app/human/schemas/recruitment.py b/src/provider/app/human/schemas/recruitment.py new file mode 100644 index 00000000..fd34c4c7 --- /dev/null +++ b/src/provider/app/human/schemas/recruitment.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class RecruitmentRead(BaseModel): + id: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class HeadcountRead(BaseModel): + recruitment_id: int + total_offers: int + accepted: int diff --git a/src/provider/app/human/schemas/talent.py b/src/provider/app/human/schemas/talent.py new file mode 100644 index 00000000..57effe60 --- /dev/null +++ b/src/provider/app/human/schemas/talent.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from pydantic import BaseModel + +from app.human.models.talent import TalentStatus + + +class TalentCreate(BaseModel): + email: str + real_name: str + auto_screening_result: str | None = None + + +class TalentUpdate(BaseModel): + real_name: str | None = None + quality: str | None = None + + model_config = {"extra": "forbid"} + + +class TalentTransition(BaseModel): + status: TalentStatus + sub_stage: str | None = None + + +class SubStageUpdate(BaseModel): + sub_stage: str | None = None + + +class TalentRead(BaseModel): + id: int + recruitment_id: int + email: str + real_name: str + status: TalentStatus + sub_stage: str | None = None + quality: str = "normal" + stage_results: dict | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/src/provider/app/human/seed.py b/src/provider/app/human/seed.py new file mode 100644 index 00000000..059fa3fb --- /dev/null +++ b/src/provider/app/human/seed.py @@ -0,0 +1,187 @@ +"""Seed data constants for demo/testing.""" +from datetime import datetime, timedelta +from hashlib import md5 + +from sqlalchemy import update +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.candidate import Candidate +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.recruitment import Recruitment +from app.human.models.talent import Talent, TalentStatus + +SEED_TRANSITIONS = { + "new": [], + "contacted": ["contacted"], + "exam_sent": ["contacted", "exam_sent"], + "exam_received": ["contacted", "exam_sent", "exam_received"], + "evaluating": ["contacted", "exam_sent", "exam_received", "evaluating"], + "interview": ["contacted", "exam_sent", "exam_received", "evaluating", "interview"], + "offer": ["contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer"], + "closed": ["closed"], +} + +DEMO_TALENTS = [ + ("new", "张一", "zhang1@demo.local", None), + ("new", "张二", "zhang2@demo.local", None), + ("new", "张三", "zhang3@demo.local", None), + ("new", "张四", "zhang4@demo.local", None), + ("new", "张五", "zhang5@demo.local", None), + ("contacted", "李一", "li1@demo.local", None), + ("contacted", "李二", "li2@demo.local", "resume_passed"), + ("contacted", "李三", "li3@demo.local", "resume_passed"), + ("contacted", "李四", "li4@demo.local", "resume_passed"), + ("contacted", "李五", "li5@demo.local", None), + ("exam_sent", "王一", "wang1@demo.local", None), + ("exam_sent", "王二", "wang2@demo.local", "taking"), + ("exam_sent", "王三", "wang3@demo.local", "taking"), + ("exam_sent", "王四", "wang4@demo.local", "taking"), + ("exam_sent", "王五", "wang5@demo.local", None), + ("exam_received", "赵一", "zhao1@demo.local", None), + ("exam_received", "赵二", "zhao2@demo.local", None), + ("exam_received", "赵三", "zhao3@demo.local", None), + ("exam_received", "赵四", "zhao4@demo.local", None), + ("exam_received", "赵五", "zhao5@demo.local", None), + ("evaluating", "孙一", "sun1@demo.local", None), + ("evaluating", "孙二", "sun2@demo.local", "exam_passed"), + ("evaluating", "孙三", "sun3@demo.local", "exam_passed"), + ("evaluating", "孙四", "sun4@demo.local", "exam_passed"), + ("evaluating", "孙五", "sun5@demo.local", None), + ("interview", "周一", "zhou1@demo.local", None), + ("interview", "周子", "zhou2@demo.local", "interview_passed"), + ("interview", "周三", "zhou3@demo.local", "interview_passed"), + ("interview", "周四", "zhou4@demo.local", "interview_passed"), + ("interview", "周五", "zhou5@demo.local", None), + ("offer", "吴一", "wu1@demo.local", None), + ("offer", "吴二", "wu2@demo.local", "accepted"), + ("offer", "吴三", "wu3@demo.local", "accepted"), + ("offer", "吴四", "wu4@demo.local", "accepted"), + ("offer", "吴五", "wu5@demo.local", None), + ("closed", "郑一", "zheng1@demo.local", None), + ("closed", "郑二", "zheng2@demo.local", None), + ("closed", "郑三", "zheng3@demo.local", None), + ("closed", "郑四", "zheng4@demo.local", None), + ("closed", "郑五", "zheng5@demo.local", None), +] + +QUALITY_MAP = { + "李二": "excellent", "李三": "excellent", "李四": "excellent", + "孙二": "excellent", "孙三": "excellent", + "周子": "excellent", + "吴二": "excellent", "吴三": "excellent", + "张五": "excellent", +} + +DEMO_EMAILS = [ + {"subject": "求职申请 - 前端开发", "sender_name": "王小明", "sender_email": "wxm@demo.local"}, + {"subject": "简历: 3年Python后端经验", "sender_name": "李芳", "sender_email": "lifang@demo.local"}, + {"subject": "应聘产品经理岗位", "sender_name": "赵磊", "sender_email": "zhaolei@demo.local"}, + {"subject": "高级Java开发求职", "sender_name": "陈静", "sender_email": "chenjing@demo.local"}, + {"subject": "【求职】数据分析师", "sender_name": "刘洋", "sender_email": "liuyang@demo.local"}, + {"subject": "UI设计师求职作品集", "sender_name": "周婷", "sender_email": "zhouting@demo.local"}, + {"subject": "寻求前端实习机会", "sender_name": "林小华", "sender_email": "linxh@demo.local"}, + {"subject": "DevOps工程师求职", "sender_name": "黄伟", "sender_email": "huangwei@demo.local"}, + {"subject": "测试工程师简历投递", "sender_name": "孙磊", "sender_email": "sunlei@demo.local"}, + {"subject": "市场运营专员求职", "sender_name": "张薇", "sender_email": "zhangwei@demo.local"}, +] + + +def build_transition_chain(target: str) -> list[str]: + """从 new 走到 target 的合法路径(不含 new 自身)。""" + return SEED_TRANSITIONS[target] + + +def seed_data(db: Session) -> None: + """Populate the database with demo talents and pending queue items.""" + import app.human.models # noqa: F401 + + r = Recruitment() + db.add(r) + db.flush() + + for target_status, name, email, sub_stage in DEMO_TALENTS: + t = Talent(recruitment_id=r.id, email=email, real_name=name) + db.add(t) + db.flush() + for s in build_transition_chain(target_status): + t.status = TalentStatus(s) + db.flush() + t.sub_stage = sub_stage + t.quality = QUALITY_MAP.get(name, "normal") + stage_map = { + "exam_sent": {"contacted": "pass"}, + "exam_received": {"contacted": "pass"}, + "evaluating": {"contacted": "pass"}, + "interview": {"contacted": "pass", "evaluating": "pass"}, + "offer": {"contacted": "pass", "evaluating": "pass", "interview": "pass"}, + } + t.stage_results = stage_map.get(target_status) + db.flush() + + db.commit() + + status_age = {"new": 0, "contacted": 2, "exam_sent": 5, "exam_received": 8, + "evaluating": 12, "interview": 15, "offer": 20, "closed": 25} + for target_status, name, email, _ in DEMO_TALENTS: + days = status_age[target_status] + if days > 0: + past = datetime.utcnow() - timedelta(days=days) + db.execute(update(Talent).where(Talent.email == email).values(updated_at=past)) + db.commit() + + email_to_candidate = {} + for target_status, name, email, _ in DEMO_TALENTS: + if email not in email_to_candidate: + c = Candidate(email=email, real_name=name) + db.add(c) + db.flush() + email_to_candidate[email] = c + + for target_status, name, email, sub_stage in DEMO_TALENTS: + talent = db.query(Talent).filter(Talent.email == email).first() + if talent: + a = Application( + candidate_id=email_to_candidate[email].id, + recruitment_id=r.id, + status=talent.status, + sub_stage=talent.sub_stage, + quality=talent.quality, + stage_results=talent.stage_results, + source="manual_seed", + ) + db.add(a) + db.flush() + + zhang3 = email_to_candidate.get("zhang3@demo.local") + if zhang3: + pooled = Application( + candidate_id=zhang3.id, recruitment_id=r.id, + status=TalentStatus.NEW, source="manual_seed", + pooled_at=datetime.utcnow(), + ) + db.add(pooled) + db.flush() + + wang5 = email_to_candidate.get("wang5@demo.local") + if wang5: + extra = Application( + candidate_id=wang5.id, recruitment_id=r.id, + status=TalentStatus.EXAM_SENT, source="manual_seed", + ) + db.add(extra) + db.flush() + + db.commit() + + for email in DEMO_EMAILS: + qi = PendingQueueItem( + message_id=md5(email["subject"].encode()).hexdigest()[:16], + subject=email["subject"], + sender_name=email["sender_name"], + sender_email=email["sender_email"], + suggested_status="contacted", + confidence="medium", + ) + db.add(qi) + db.commit() diff --git a/src/provider/app/human/services/__init__.py b/src/provider/app/human/services/__init__.py new file mode 100644 index 00000000..b7a7a42b --- /dev/null +++ b/src/provider/app/human/services/__init__.py @@ -0,0 +1,4 @@ +"""HR services.""" +from app.human.services.pipeline import get_pipeline + +__all__ = ["get_pipeline"] diff --git a/src/provider/app/human/services/ai_classifier.py b/src/provider/app/human/services/ai_classifier.py new file mode 100644 index 00000000..16a08a29 --- /dev/null +++ b/src/provider/app/human/services/ai_classifier.py @@ -0,0 +1,177 @@ +"""AI分类器 — 可插拔,未配置时返回 None 回退到规则分类。""" + +import json +import logging +from dataclasses import dataclass + +import httpx +from sqlalchemy.orm import Session + +from app.human.models.ai_config import AIConfig +from app.human.services.email_matcher import MatchResult + +logger = logging.getLogger(__name__) + +_DEFAULT_PROMPT = """你是一个招聘邮件分类助手。根据邮件内容判断候选人处于招聘管道的哪个阶段,并提取候选人真实姓名。 + +规则: +1. suggested_status 必须是以下英文值之一(不能是中文): + new — 新投递简历/新应聘 + contacted — 回复了HR的联系邮件/普通咨询 + exam_sent — 询问笔试相关/笔试通知 + exam_received — 提交笔试答案/完成笔试 + evaluating — 询问评估进度/审核中 + interview — 面试相关沟通/面试感谢信 + offer — Offer沟通/接受Offer + closed — 放弃机会/拒绝offer +2. extracted_name:从邮件正文或署名中提取候选人真实姓名,找不到则返回null""" + + +@dataclass +class AiClassification: + suggested_status: str | None = None + confidence: str = "low" + classifier_reason: str | None = None + extracted_name: str | None = None + merge_result: str | None = None + match: MatchResult | None = None + + +def ai_classify( + subject: str, + body_text: str | None, + sender_name: str | None, + sender_email: str, + attachments: list[dict] | None = None, + match: MatchResult | None = None, + db: Session | None = None, +) -> AiClassification | None: + """AI分类入口。读取 DB 中的 AI 配置,调用 AI 接口分类。 + + 当 AI 未配置或调用失败时返回 None,由 classifier.py 的回退机制接管。 + """ + if db is None: + return None + + cfg = db.query(AIConfig).first() + if not cfg or not cfg.enabled or not cfg.api_key_encrypted: + return None + + body_text_truncated = (body_text or "")[:2000] + user_prompt = cfg.prompt_template or _DEFAULT_PROMPT + + # Inject email context — this works whether or not the prompt template has placeholders + email_context = ( + f"\n\n---\n邮件信息:\n" + f"发件人: {sender_name or ''} <{sender_email}>\n" + f"主题: {subject}\n" + f"正文: {body_text_truncated}\n" + f"---\n" + f"请根据邮件内容选择最匹配的阶段值(必须用英文值)并提取候选人姓名。尽量给出判断而非null。\n" + f'仅返回以下JSON格式:\n' + f'{{"suggested_status": "...", "confidence": "high/medium/low", "reason": "...", "extracted_name": "姓名或null"}}' + ) + full_content = user_prompt + email_context + + messages = [ + { + "role": "user", + "content": full_content, + } + ] + + url = (cfg.base_url or "https://api.openai.com/v1").rstrip("/") + "/chat/completions" + headers = { + "Authorization": f"Bearer {cfg.api_key_encrypted}", + "Content-Type": "application/json", + } + payload = { + "model": cfg.model or "gpt-4o-mini", + "messages": messages, + "temperature": 0.1, + "max_tokens": 300, + } + + last_error: Exception | None = None + for attempt in range(max(1, cfg.retry_times + 1)): + try: + resp = httpx.post( + url, + headers=headers, + json=payload, + timeout=cfg.timeout_seconds or 30, + ) + logger.warning("AI API response status=%d body_preview=%s", resp.status_code, resp.text[:500]) + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"].strip() + # Reasoning models (DeepSeek-R1, deepseek-v4-flash etc.) may return + # reasoning_content before content — only the final content is in "content". + # Try to extract JSON from the content (find first { and last }) + if "{" in content and "}" in content: + json_start = content.index("{") + json_end = content.rindex("}") + 1 + content = content[json_start:json_end] + # Strip markdown code fence if present + if content.startswith("```"): + content = content.split("\n", 1)[-1] if "\n" in content else content[3:] + content = content.rsplit("```", 1)[0].strip() + result = json.loads(content) + status = result.get("suggested_status") + confidence = result.get("confidence", "low") + reason = result.get("reason", "") + extracted_name = result.get("extracted_name") + + if status and status not in _VALID_STATUSES: + # Try fuzzy match — map Chinese/creative labels to valid statuses + status_lower = status.lower() + fuzzy_map = { + "新投递": "new", "新应聘": "new", "求职": "new", "投递": "new", + "面试结束": "interview", "面试": "interview", "面试反馈": "interview", + "笔试提交": "exam_received", "笔试": "exam_received", + "笔试发送": "exam_sent", "笔试通知": "exam_sent", + "评估": "evaluating", + "offer": "offer", "录用": "offer", + "放弃": "closed", "拒绝": "closed", + } + mapped = None + for k, v in fuzzy_map.items(): + if k in status or k in status_lower: + mapped = v + break + if mapped: + status = mapped + else: + logger.warning("AI returned unknown status: %s", status) + status = None + confidence = "low" + + if status is None: + logger.warning("AI returned no valid status, falling back to keyword rules") + return None + + return AiClassification( + suggested_status=status, + confidence=confidence or "low", + classifier_reason=reason, + extracted_name=extracted_name, + merge_result=match.merge_result if match else None, + match=match, + ) + + except httpx.TimeoutException as e: + last_error = e + logger.warning("AI request timeout (attempt %d/%d)", attempt + 1, cfg.retry_times + 1) + except (httpx.HTTPStatusError, json.JSONDecodeError, KeyError, IndexError) as e: + last_error = e + logger.warning("AI request failed (attempt %d/%d): %s", attempt + 1, cfg.retry_times + 1, e) + break # Don't retry on HTTP errors or parse errors + + logger.error("AI classification failed after %d attempts: %s", cfg.retry_times + 1, last_error) + return None + + +_VALID_STATUSES = { + "new", "contacted", "exam_sent", "exam_received", + "evaluating", "interview", "offer", "closed", +} diff --git a/src/provider/app/human/services/classifier.py b/src/provider/app/human/services/classifier.py new file mode 100644 index 00000000..3795447e --- /dev/null +++ b/src/provider/app/human/services/classifier.py @@ -0,0 +1,134 @@ +"""服务端分类引擎 — 三层分类:快速过滤 → 历史关联 → 独立分类。""" + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.human.services.ai_classifier import AiClassification, ai_classify +from app.human.services.email_matcher import MatchResult, match_by_email + +_INTERNAL_DOMAINS: list[str] = [] +_AUTO_REPLY_KEYWORDS = ["自动回复", "外出", "休假", "out of office", "auto-reply"] + +_STATUS_KEYWORDS: dict[str, list[str]] = { + "contacted": ["应聘", "求职", "简历", "申请", "投递", "个人简历"], + "exam_sent": ["笔试邀请", "笔试通知", "在线考试"], + "exam_received": ["笔试答案", "答题", "笔试完成", "提交答卷", "作答", "试卷", "已完成"], + "evaluating": ["评估", "审核简历", "简历评估"], + "interview": ["面试感谢", "面试反馈", "面试安排", "面试邀请", "确认参加", "时间安排"], + "offer": ["offer", "录用通知", "入职邀请", "薪酬确认", "接受 offer", "入职"], + "closed": ["放弃", "退出", "拒绝", "不考虑", "辞职"], +} + + +@dataclass +class EmailClassification: + suggested_status: str | None + confidence: str + classifier_source: str + classifier_reason: str | None + merge_result: str | None + extracted_name: str | None = None + match: MatchResult | None = None + + +def classify( + subject: str, + body_text: str | None, + sender_name: str | None, + sender_email: str, + db: Session, + attachments: list[dict] | None = None, +) -> EmailClassification: + """三层分类入口。""" + # Layer 1: 快速过滤 + filtered = _fast_filter(subject, sender_email) + if filtered: + return EmailClassification( + suggested_status=None, + confidence="reject", + classifier_source="rule", + classifier_reason=filtered, + merge_result=None, + ) + + # Layer 2: 历史关联 + match = match_by_email(sender_email, db, subject=subject) + + # Layer 3: AI 分类(可插拔,未配置时回退到关键词) + ai_result = ai_classify( + subject=subject, + body_text=body_text, + sender_name=sender_name, + sender_email=sender_email, + attachments=attachments, + match=match, + db=db, + ) + if ai_result is not None: + return EmailClassification( + suggested_status=ai_result.suggested_status, + confidence=ai_result.confidence, + classifier_source="ai", + classifier_reason=ai_result.classifier_reason, + extracted_name=ai_result.extracted_name, + merge_result=ai_result.merge_result or match.merge_result, + match=ai_result.match or match, + ) + + # Layer 4: 关键词分类(AI 回退) + status, conf, reason = _keyword_classify(subject, body_text, attachments) + + return EmailClassification( + suggested_status=status, + confidence=conf, + classifier_source="rule", + classifier_reason=reason, + merge_result=match.merge_result, + match=match, + ) + + +def _fast_filter(subject: str, sender_email: str) -> str | None: + subject_lower = subject.lower() + for kw in _AUTO_REPLY_KEYWORDS: + if kw in subject_lower: + return f"自动回复邮件: 命中关键词 '{kw}'" + for domain in _INTERNAL_DOMAINS: + if sender_email.endswith(f"@{domain}"): + return f"内部邮箱: {sender_email}" + return None + + +def _keyword_classify( + subject: str, + body_text: str | None, + attachments: list[dict] | None = None, +) -> tuple[str | None, str, str | None]: + subject_lower = subject.lower() + combined = subject_lower + if body_text: + combined += " " + body_text.lower() + + matched: list[tuple[str, str]] = [] + for status, keywords in _STATUS_KEYWORDS.items(): + for kw in keywords: + if kw in combined: + matched.append((status, kw)) + + if not matched: + return None, "low", None + + groups: dict[str, int] = {} + for s, _ in matched: + groups[s] = groups.get(s, 0) + 1 + best = max(groups, key=groups.get) + cnt = groups[best] + + conf = "high" if cnt >= 2 else "medium" + kw_str = ", ".join(f"{s}({kw})" for s, kw in matched) + has_att = attachments and len(attachments) > 0 + att_note = "+附件" if has_att else "" + reason = f"命中关键词: [{kw_str}]{att_note}" + + return best, conf, reason diff --git a/src/provider/app/human/services/email_matcher.py b/src/provider/app/human/services/email_matcher.py new file mode 100644 index 00000000..4efe1159 --- /dev/null +++ b/src/provider/app/human/services/email_matcher.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.candidate import Candidate + + +@dataclass +class MatchResult: + exists: bool + candidate_id: int | None = None + candidate_name: str | None = None + active_application_id: int | None = None + merge_result: str = "new" # "new" | "existing_auto" | "existing_review" + + +def _normalize_email(email: str) -> str: + return email.strip().lower() + + +def _subject_matches_recruitment(subject: str, recruitment_title: str) -> bool: + if not recruitment_title: + return False + keywords = recruitment_title.lower().split() + subject_lower = subject.lower() + return any(kw in subject_lower for kw in keywords) + + +def match_by_email(email: str, db: Session, subject: str = "") -> MatchResult: + if not email: + return MatchResult(exists=False) + + normalized = _normalize_email(email) + + candidates = ( + db.query(Candidate) + .filter(func.lower(Candidate.email) == normalized) + .order_by(Candidate.created_at.desc()) + .all() + ) + + if not candidates: + return MatchResult(exists=False) + + # Multiple Candidates with same email → ambiguous, escalate + if len(candidates) > 1: + return MatchResult( + exists=True, + merge_result="existing_review", + ) + + candidate = candidates[0] + active_apps = ( + db.query(Application) + .filter( + Application.candidate_id == candidate.id, + Application.deactivated_at.is_(None), + ) + .order_by(Application.created_at.desc()) + .all() + ) + + if not active_apps: + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + merge_result="existing_review", + ) + + # Single active application + if len(active_apps) == 1: + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + active_application_id=active_apps[0].id, + merge_result="existing_auto", + ) + + # Multiple active applications: try to disambiguate by subject + for app in active_apps: + recruitment_title = app.recruitment.title if hasattr(app, "recruitment") and app.recruitment else "" + if recruitment_title and _subject_matches_recruitment(subject, recruitment_title): + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + active_application_id=app.id, + merge_result="existing_auto", + ) + + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + merge_result="existing_review", + ) diff --git a/src/provider/app/human/services/export.py b/src/provider/app/human/services/export.py new file mode 100644 index 00000000..0e39726e --- /dev/null +++ b/src/provider/app/human/services/export.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.candidate import Candidate +from app.human.models.correction_log import CorrectionLog +from app.human.models.pending_queue import PendingQueueItem +from app.human.schemas.export import TrainingPairItem + + +def get_training_pairs( + db: Session, + skip: int = 0, + limit: int = 100, +) -> list[TrainingPairItem]: + rows = ( + db.query(Application, PendingQueueItem) + .join(PendingQueueItem, Application.source_queue_item_id == PendingQueueItem.id) + .filter(Application.source_queue_item_id.isnot(None)) + .order_by(Application.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + result = [] + for app, queue_item in rows: + corrections = ( + db.query(CorrectionLog) + .filter(CorrectionLog.queue_item_id == queue_item.id) + .all() + ) + corrected_fields = [c.field_name for c in corrections] + + candidate = db.query(Candidate).filter(Candidate.id == app.candidate_id).first() + + result.append(TrainingPairItem( + queue_id=queue_item.id, + subject=queue_item.subject, + body=queue_item.body, + sender_email=queue_item.sender_email, + suggested_status=queue_item.suggested_status, + final_status=app.status.value if app.status else None, + final_real_name=candidate.real_name if candidate else None, + final_email=candidate.email if candidate else None, + hr_action=queue_item.hr_status, + corrected_fields=corrected_fields, + )) + + return result + + +def count_training_pairs(db: Session) -> int: + return ( + db.query(Application) + .filter(Application.source_queue_item_id.isnot(None)) + .count() + ) diff --git a/src/provider/app/human/services/headcount.py b/src/provider/app/human/services/headcount.py new file mode 100644 index 00000000..84c19f0f --- /dev/null +++ b/src/provider/app/human/services/headcount.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.talent import TalentStatus + + +def get_headcount(db: Session, recruitment_id: int) -> dict: + base = db.query(Application).filter(Application.recruitment_id == recruitment_id) + total_offers = base.filter(Application.status == TalentStatus.OFFER).count() + accepted = base.filter( + Application.status == TalentStatus.OFFER, + Application.sub_stage == "accepted", + ).count() + return { + "recruitment_id": recruitment_id, + "total_offers": total_offers, + "accepted": accepted, + } diff --git a/src/provider/app/human/services/material_service.py b/src/provider/app/human/services/material_service.py new file mode 100644 index 00000000..3fc9d9f2 --- /dev/null +++ b/src/provider/app/human/services/material_service.py @@ -0,0 +1,92 @@ +"""材料生成服务 — 从邮件原始数据生成结构化材料产物。""" + +import json +import os + +from sqlalchemy.orm import Session + +from app.human.models.material import MaterialArtifact + + +def write_material_artifact( + db: Session, + queue_item_id: int | None, + candidate_id: int | None, + artifact_type: str, + content_json: str | None = None, + file_path: str | None = None, +) -> MaterialArtifact: + artifact = MaterialArtifact( + queue_item_id=queue_item_id, + candidate_id=candidate_id, + artifact_type=artifact_type, + content_json=content_json, + file_path=file_path, + ) + db.add(artifact) + db.flush() + return artifact + + +def generate_body_artifact( + db: Session, + queue_item_id: int | None, + candidate_id: int | None, + body: str | None, + body_text: str | None, + materials_dir: str = "", +) -> MaterialArtifact | None: + if not body and not body_text: + return None + + content = {"body_html": body or "", "body_text": body_text or ""} + content_str = json.dumps(content, ensure_ascii=False) + + file_path = None + if materials_dir: + artifact_dir = os.path.join(materials_dir, str(queue_item_id)) + os.makedirs(artifact_dir, exist_ok=True) + fp = os.path.join(artifact_dir, "body.json") + with open(fp, "w", encoding="utf-8") as f: + f.write(content_str) + file_path = fp + + return write_material_artifact( + db=db, queue_item_id=queue_item_id, candidate_id=candidate_id, + artifact_type="body_text", content_json=content_str, file_path=file_path, + ) + + +def generate_attachment_artifact( + db: Session, + queue_item_id: int | None, + candidate_id: int | None, + attachments: list[dict] | None, + materials_dir: str = "", +) -> MaterialArtifact | None: + if not attachments: + return None + + content_str = json.dumps(attachments, ensure_ascii=False) + + file_path = None + if materials_dir: + artifact_dir = os.path.join(materials_dir, str(queue_item_id)) + os.makedirs(artifact_dir, exist_ok=True) + fp = os.path.join(artifact_dir, "attachments.json") + with open(fp, "w", encoding="utf-8") as f: + f.write(content_str) + file_path = fp + + return write_material_artifact( + db=db, queue_item_id=queue_item_id, candidate_id=candidate_id, + artifact_type="attachment_meta", content_json=content_str, file_path=file_path, + ) + + +def get_artifacts_by_queue(db: Session, queue_id: int) -> list[MaterialArtifact]: + return db.query(MaterialArtifact).filter(MaterialArtifact.queue_item_id == queue_id).all() + + +def get_artifacts_by_candidate(db: Session, candidate_id: int) -> list[MaterialArtifact]: + return db.query(MaterialArtifact).filter(MaterialArtifact.candidate_id == candidate_id).all() diff --git a/src/provider/app/human/services/pipeline.py b/src/provider/app/human/services/pipeline.py new file mode 100644 index 00000000..9d118335 --- /dev/null +++ b/src/provider/app/human/services/pipeline.py @@ -0,0 +1,43 @@ +"""Pipeline aggregation service.""" +from sqlalchemy.orm import Session + +from app.human.models.talent import Talent, TalentStatus + + +def get_pipeline(db: Session) -> dict: + stages = {} + total = 0 + for status in TalentStatus: + talents = ( + db.query(Talent) + .filter(Talent.status == status) + .order_by(Talent.updated_at.desc()) + .all() + ) + stages[status.value] = [_talent_to_card(t) for t in talents] + total += len(talents) + + need_attention = len(stages.get("exam_received", [])) + len(stages.get("evaluating", [])) + return { + "stages": stages, + "summary": { + "total": total, + "by_stage": {s.value: len(stages.get(s.value, [])) for s in TalentStatus}, + "need_attention": need_attention, + }, + } + + +def _talent_to_card(t: Talent) -> dict: + return { + "id": t.id, + "email": t.email, + "real_name": t.real_name, + "recruitment_id": t.recruitment_id, + "status": t.status.value, + "sub_stage": t.sub_stage, + "quality": t.quality, + "stage_results": t.stage_results, + "created_at": t.created_at.isoformat() if t.created_at else "", + "updated_at": t.updated_at.isoformat() if t.updated_at else "", + } diff --git a/src/provider/app/human/services/pool.py b/src/provider/app/human/services/pool.py new file mode 100644 index 00000000..f8a0d822 --- /dev/null +++ b/src/provider/app/human/services/pool.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone + +from sqlalchemy.orm import Session, joinedload + +from app.human.models.application import Application +from app.human.models.talent import TalentStatus + + +def pool_application(db: Session, application_id: int) -> Application | None: + app = db.query(Application).filter(Application.id == application_id).first() + if not app: + return None + if app.pooled_at is not None: + return app + now = datetime.now(timezone.utc) + app.pooled_at = now + app.deactivated_at = now + app.status = TalentStatus.CLOSED + app.sub_stage = None + db.commit() + db.refresh(app) + return app + + +def unpool_application(db: Session, application_id: int, recruitment_id: int) -> Application | None: + original = db.query(Application).filter(Application.id == application_id).first() + if not original: + return None + new_app = Application( + candidate_id=original.candidate_id, + recruitment_id=recruitment_id, + source=original.source, + ) + db.add(new_app) + db.commit() + db.refresh(new_app) + return new_app + + +def get_pooled_applications(db: Session, skip: int = 0, limit: int = 100) -> list[Application]: + return ( + db.query(Application) + .options(joinedload(Application.candidate)) + .filter(Application.pooled_at.isnot(None)) + .order_by(Application.pooled_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) diff --git a/src/provider/app/human/services/resume_parser.py b/src/provider/app/human/services/resume_parser.py new file mode 100644 index 00000000..b3f1ad94 --- /dev/null +++ b/src/provider/app/human/services/resume_parser.py @@ -0,0 +1,105 @@ +import re + +from dataclasses import dataclass, field + + +@dataclass +class ParseResult: + name: str | None = None + phone: str | None = None + email: str | None = None + education: list[dict] = field(default_factory=list) + experience: list[dict] = field(default_factory=list) + raw_text: str | None = None + + +class ResumeParser: + """Interface for resume parsing. Override `parse` to implement actual parsing.""" + + def parse(self, file_path: str) -> ParseResult: + raise NotImplementedError + + +class NoopResumeParser(ResumeParser): + """Placeholder parser that returns an empty result.""" + + def parse(self, file_path: str) -> ParseResult: + return ParseResult() + + +class PdfPlumberResumeParser(ResumeParser): + """PDF resume parser using pdfplumber. + + Extracts text from text-based PDFs and applies regex patterns to + extract structured fields (name, phone, email, education, experience). + """ + + _PHONE_RE = re.compile(r"1[3-9]\d{9}") + _EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+") + _NAME_RE = re.compile(r"姓名[::]\s*(\S+)") + _EDU_KEYWORDS = ("大学", "学院", "本科", "硕士", "博士", "毕业", "专业", "学位") + _EXP_KEYWORDS = ("公司", "任职", "担任", "工作经历", "工作") + + def parse(self, file_path: str) -> ParseResult: + try: + import pdfplumber + + with pdfplumber.open(file_path) as pdf: + raw_text = "\n".join( + page.extract_text() or "" for page in pdf.pages + ) + except Exception as exc: + import logging + + logging.warning("PdfPlumberResumeParser: failed to parse %s: %s", file_path, exc) + return ParseResult(raw_text=None) + + if not raw_text.strip(): + return ParseResult(raw_text=None) + + name = self._extract_name(raw_text) + phone = self._extract_phone(raw_text) + email = self._extract_email(raw_text) + education = self._extract_education(raw_text) + experience = self._extract_experience(raw_text) + + return ParseResult( + name=name, + phone=phone, + email=email, + education=education, + experience=experience, + raw_text=raw_text, + ) + + def _extract_name(self, text: str) -> str | None: + m = self._NAME_RE.search(text) + if m: + return m.group(1) + return None + + def _extract_phone(self, text: str) -> str | None: + m = self._PHONE_RE.search(text) + return m.group(0) if m else None + + def _extract_email(self, text: str) -> str | None: + m = self._EMAIL_RE.search(text) + return m.group(0) if m else None + + def _extract_education(self, text: str) -> list[dict]: + lines = text.split("\n") + items = [] + for line in lines: + line = line.strip() + if any(kw in line for kw in self._EDU_KEYWORDS): + items.append({"raw": line}) + return items + + def _extract_experience(self, text: str) -> list[dict]: + lines = text.split("\n") + items = [] + for line in lines: + line = line.strip() + if any(kw in line for kw in self._EXP_KEYWORDS): + items.append({"raw": line}) + return items diff --git a/src/provider/app/human/services/transition.py b/src/provider/app/human/services/transition.py new file mode 100644 index 00000000..c27e6a94 --- /dev/null +++ b/src/provider/app/human/services/transition.py @@ -0,0 +1,42 @@ +from app.human.models.application import Application +from app.human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus + + +def transition_application( + app: Application, + target: TalentStatus, + sub_stage: str | None = None, +) -> Application: + """Transition an Application to a new status. + + Pure Application logic — no Talent awareness. + Caller is responsible for syncing Talent separately. + """ + if target not in STATUS_TRANSITIONS.get(app.status, []): + raise ValueError(f"Cannot transition from {app.status.value} to {target.value}") + + old_status = app.status + app.status = target + + if target != old_status: + app.sub_stage = None + + if sub_stage is not None and target in ALLOWED_STATUSES_FOR_SUB_STAGE: + app.sub_stage = sub_stage + + stage_key = old_status.value + if stage_key in ("contacted", "evaluating", "interview", "offer"): + if not (stage_key == "evaluating" and target.value == "exam_sent"): + if app.stage_results is None: + app.stage_results = {} + app.stage_results[stage_key] = "pass" if target.value != "closed" else "fail" + + return app + + +def sync_talent_from_application(talent: Talent, app: Application) -> None: + """Copy derived state fields from Application to an existing Talent.""" + talent.status = app.status + talent.sub_stage = app.sub_stage + talent.quality = app.quality + talent.stage_results = app.stage_results diff --git a/src/provider/pyproject.toml b/src/provider/pyproject.toml new file mode 100644 index 00000000..69f65dea --- /dev/null +++ b/src/provider/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "qtadmin-provider" +version = "0.1.0" +description = "qtadmin provider API" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.136.1", + "uvicorn[standard]>=0.46.0", + "sqlalchemy>=2.0.0", + "pydantic>=2.0.0", +] + +[tool.setuptools.packages.find] +include = ["app*"] diff --git a/src/provider/run.sh b/src/provider/run.sh new file mode 100644 index 00000000..95857411 --- /dev/null +++ b/src/provider/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/linli/桌面/qt-hr/qtadmin/src/provider +.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8000 diff --git a/src/provider/uv.lock b/src/provider/uv.lock new file mode 100644 index 00000000..8cb50e3c --- /dev/null +++ b/src/provider/uv.lock @@ -0,0 +1,483 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qtadmin-provider" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.136.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/src/studio/.env.example b/src/studio/.env.example new file mode 100644 index 00000000..e4871d97 --- /dev/null +++ b/src/studio/.env.example @@ -0,0 +1,3 @@ +# fixtures(由 flutter_dotenv 加载,.env 需注册在 pubspec.yaml assets 中) +QTADMIN_FIXTURES_PATH= +# 例如: QTADMIN_FIXTURES_PATH=/home/user/repos/qtadmin/assets/fixtures diff --git a/src/studio/.gitignore b/src/studio/.gitignore new file mode 100644 index 00000000..8eb2b30a --- /dev/null +++ b/src/studio/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# 环境变量 +environment_config.dart diff --git a/.metadata b/src/studio/.metadata similarity index 100% rename from .metadata rename to src/studio/.metadata diff --git a/src/studio/AGENTS.md b/src/studio/AGENTS.md new file mode 100644 index 00000000..f4931922 --- /dev/null +++ b/src/studio/AGENTS.md @@ -0,0 +1,48 @@ +# Agent Guidelines for qtadmin_studio + +详见 [CONTRIBUTING.md](CONTRIBUTING.md) 了解完整的开发原则和本地开发流程。 + +## 原则 + +### 架构决策归人,AI 不主动分包 + +分包是战略决策(边界在哪、复用价值够不够),由人类主导。AI 不主动建包或移动代码。 + +新功能和跨域逻辑先写在主项目里,稳定后人类决定是否分包。AI 在以下时机提醒,但不执行: +- 模块错误明显增加 +- 单次改动涉及大量文件 +- 功能趋于稳定且边界清晰 +- 出现第二个潜在消费者 + +已验证的模式才能固化。先跑通、再稳定、最后才考虑分包。 + +## AI 上下文 + +- 项目已完成领域分包:模型、BLoC、页面、视图已按领域提取为独立包 +- `packages/` 下 7 个包,主项目仅保留导航加载 + 路由 + 入口 +- 数据加载使用 `data_sources` 包的 `DataLoader` + `FileSource` +- 测试使用 `DataLoader.inject()` 注入数据,不依赖真实文件 + +## 维护工作流 + +### 已有领域 + +直接改对应包,包内独立开发测试。例如咨询加新功能:改 `packages/qtadmin-qtconsult/`,跑它的测试,主项目只需更新版本引用。 + +### 新领域 / 跨领域 + +先写在主项目里,不建新包。跨领域 glue 在主项目处理。 + +分包由人类控制,AI 不主动分包。当出现以下信号时,AI 应提醒人类考虑分包: +- 模块错误明显增加,测试维护困难 +- 单次改动涉及大量文件 +- 功能趋于稳定,边界清晰 +- 出现第二个潜在消费者 + +### 改基础设施 + +改 `data_sources` 等底层包,所有依赖它的包重新 `pub get`。 + +### 提交流程 + +改代码 → `dart analyze` → 跑改动的包测试 → 跑主项目测试 → 提交 diff --git a/src/studio/CHANGELOG.md b/src/studio/CHANGELOG.md new file mode 100644 index 00000000..1001f2a5 --- /dev/null +++ b/src/studio/CHANGELOG.md @@ -0,0 +1,167 @@ +# Changelog + +## v0.1.2 + +### Refactor + +- 分包续:导航提取为 `qtadmin-navigation` 包,与 WorkspaceInfo 解耦 +- 分包续:仪表盘提取为 `qtadmin-dashboard` 包,DashboardBloc 从 AppBloc 拆分 +- 数据源简化:用 `FileSource` 替代 `BundleSource`,移除 `data/` 的 pubspec assets 注册 + +### Chore + +- 保持 `views/` 目录结构(.gitkeep) +- CI 工作流:构建前复制 fixture + +### Docs + +- AGENTS.md 补充维护工作流 +- AI 上下文与开发原则分离至 CONTRIBUTING.md + +### Clean + +- 移除旧文件:`navigation.dart`(git 跟踪残留)、`stat_item.dart`(未使用) + +## v0.1.1 + +### Refactor + +- 分包:模型、BLoC、常量、页面按领域提取为独立包(qtadmin-qtconsult / qtadmin-qtclass / qtadmin-think / qtadmin-org) +- DataSource/DataLoader/DataResult 基础设施提取为 data_sources 包 +- ConsultBloc 随咨询领域包迁移 + +### Chore + +- deploy.yml 触发条件从 push 改为 release published + +## v0.1.0 + +### Refactor + +- 路由系统迁移:纯 GoRouter 替代 AppRouter 字符串派发,redirect 统一管理 AppLifecycle +- P0 路由表合并:`Map` 自包含,消除 routeId→screen 双重映射 +- P1 Section 缓存:`_SidebarShell` StatefulWidget 缓存子树,workspace 不变时减少 50%+ 无谓重建 +- P2 ConsultBloc 生命周期提升至 ShellRoute,跨页面保持咨询状态 +- 全模型 `XxxData` → `Xxx` 重命名(Dashboard、BusinessUnit、Thinking 等 20+ 模型) +- `NavItem` 构造参数 `builder` → `routeId`,与路由表解耦 + +### Added + +- 166 测试全覆盖:sources(DataLoader/DataResult)、blocs(ConsultBloc)、screens(dashboard/business_detail/function_detail/qtconsult)、views(全部 7 个 widget 组件) +- `DataSource` 抽象 + `DataResult` sealed class + `DataLoader` 泛型类 + +### Fixed + +- 切换工作空间 `_router` 重新赋值报错(`late final` → `late`) + +### Chore + +- pre-commit 仅 `dart analyze`,`flutter test` 由 CI 覆盖 + +## v0.0.7 + +### Docs + +- 关键决策记录:14 项架构选型决策及理由 +- 债务评估更新:P0-P2 全部完成,综合评级降至低 +- 文档重组:拆分为 decision.md / refactor.md,新增 dev/README.md +- 删除 ROADMAP.md(P0-P2 全部达成) + +## v0.0.6 + +### Refactor +- 重命名 租户(Tenant) → Workspace工作空间(Workspace):中文文档、Dart 代码标识符、JSON fixture 键全量替换 + - `TenantType` → `WorkspaceType`,`TenantInfo` → `WorkspaceInfo`,`TenantSwitcher` → `WorkspaceSwitcher` + - 所有相关字段/参数/变量同步更新 +- 路由重构:metadata.json 的 items 改为纯 name 列表,移除 label/icon/pageType + - 新增 `RouteConfig` 集中管理所有路由定义 + - `AppRouter.buildScreen()` 通过 `RouteConfig` 分发 +- 数据加载改为缓存注入:移除 `rootBundle` 和 pubspec.yaml assets + - 所有 Loader 添加 `inject()` 方法 + - fixture JSON 移至 `data/` 本地目录 +- 组织管理代表改为多对多:`institutionId` → `institutionIds: List` + +### Added +- 组织管理页面(`OrgScreen`):机构看板、代表履职(可展开详情)、职级流动 +- 组织管理数据模型(`OrgDashboardData` / `OrgInstitutionData` / `OrgRepresentativeData` / `OrgRankData` / `OrgPromotionData`) +- `OrgLoader` fixture 加载 + 缓存注入 +- 路由独立模块 `lib/router.dart` + +### Fixed +- 修复数据加载完成前侧边栏空 `workspaces` 列表导致的 `RangeError`(预存 bug) +- 切换工作空间时 `_router` 重新赋值报错(`late final` → `late`) + +### Tests +- 新增 `org_test.dart`(13 个模型测试) +- 新增 `org_screen_test.dart`(11 个 widget 测试) +- 更新 `metadata_test.dart` 适应新的纯 name 格式 + +## v0.0.5 + +### 新增 +- `QtClassScreen`:量潮课堂独立页面,展示四个组成部分(校企合作/实训基地/内部教学/一对一) +- `QtClassData` 数据模型 + `qtclass.json` fixture + loader +- `ThinkingData` 数据模型 + `thinking.json` fixture + loader,思考页面数据抽取为 fixture 驱动 +- 数据规范文档:`qtclass.md`、`thinking.md`、`dashboard.md` + +### 重命名 +- 全景图→仪表盘,全线英文 `panorama` → `dashboard` + - `PanoramaScreen` → `DashboardScreen`,`PanoramaData` → `DashboardData` + - `panorama_loader.dart` → `dashboard_loader.dart`,`panoramaPath` → `dashboardPath` + - fixture 文件同步重命名,所有 import/变量名更新 + +### 测试 +- 新增 `thinking_test.dart`、`thinking_screen_test.dart`(模型 + widget) +- 新增 `qtclass_test.dart`、`qtclass_screen_test.dart`(模型 + widget) +- 全部 94 个测试通过 + +## v0.0.4 + +### 新增 +- 根 `metadata.json` 全局注册表:Workspace工作空间清单 + 段定义(dividerBefore 规则) +- `NavSidebar` 独立组件,封装侧边栏全部布局逻辑 +- 数据规范文档目录(`docs/drd/`):metadata schema + qtconsult schema + +### 优化 +- 导航组件从 `main.dart` 私有类提取为公开组件(NavIcon / WorkspaceSwitcher / NavSidebar) +- `lib/widgets/` → `lib/views/`,widget test 直接 import 公开组件,不再重复定义 +- 新增Workspace工作空间只需写 fixture 文件,不再改 Dart 代码 +- 文档结构重组:主仓库 dev / ADD / DRD / 子模块 doc 分工明确 + +## v0.0.3 + +### 新增 +- 量潮咨询详情页:双栏联动面板(信息看板 + 策略看板),支持发现记录、策略修正、决策链路管理 +- 咨询数据模型(DiscoveryData / StakeholderData / StrategyRevisionData)及 JSON 加载服务 +- 发现→策略强制联动:高风险/需关注发现自动追加策略审视记录 +- ADD 架构设计文档 + +### 优化 +- 导航重构:`_workspaces` 改为实例字段,支持动态页面加载 +- 资源注册:`qtconsult.json` 注册为 Flutter asset + +## v0.0.2 + +### 新增 +- 多Workspace工作空间架构:量潮创始人(全景图/思考/写作)与量潮科技(全景图/数据/课堂/咨询/云) +- 思考页面(ThinkingScreen):认知建构与思维演进分析报告,包含阶段时间线、情绪统计、心智模型洞察 +- Workspace工作空间切换器(PopupMenuButton),支持一键切换Workspace工作空间及对应导航 + +### 优化 +- 全景图页面支持动态Workspace工作空间名称 +- 侧边栏布局调优(减小间距,提升紧凑度) +- Flutter 依赖升级至最新兼容版本 + +## v0.0.1 + +### 新增 +- 全景图主页面(今日看板),包含业务线决策卡片和职能线指标卡片 +- 业务线详情页,支持按业务线查看决策事项 +- 决策卡片交互(批准/驳回/附条件) +- 响应式布局(桌面多列 / 移动端单列+折叠) + +### 架构 +- 全平台应用名统一为 `qtadmin_studio` / 量潮管理后台 +- 全景图数据抽离至 `assets/panorama.json`,支持热更新 +- Model 层支持 JSON 反序列化 +- Flutter 依赖升级至最新兼容版本 diff --git a/src/studio/CONTRIBUTING.md b/src/studio/CONTRIBUTING.md new file mode 100644 index 00000000..d95443f4 --- /dev/null +++ b/src/studio/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing + +## 开发原则 + +### 1. 模型归模型,工具归工具 + +`models/` 只放 freezed 数据类。颜色工具(`theme.dart`)、UI 映射函数与模型解耦,放在根目录。 + +### 2. 不提前抽象 + +先做可工作的简单实现,等重复模式出现再抽象。 + +### 3. 少即是多 + +文件宁可大一点也不要拆碎。同类文件放在一起,不建多余子目录。 + +### 4. freezed 没有替代品 + +手写 `fromJson` 不安全,freezed 从第一天就该上。字段默认值用 `@Default`,枚举 fallback 用 `@JsonKey(fromJson:)`。 + +### 5. 自定义方法用 extension,不在 freezed 类里写 + +`._()` 构造器 + `implements` vs `extends` 问题过多。自定义 getter/method 写成 `extension XxxX on Xxx` 更干净。 + +### 6. 命名即设计 + +`XxxData` 后缀是噪音,改为 `Xxx`。 + +### 7. BLoC 解决的是架构问题 + +不是状态管理工具。用它拆 God 类和 God State,而不是替代 `setState` 做 UI 切换。 + +### 8. pre-commit 与 CI 互补 + +pre-commit 快跑 `dart analyze`,CI 跑完整 `flutter test`。两层的原因不是功能重复,而是环境依赖性不同。 + +### 9. 第一次就要想清楚数据源抽象 + +`DataSource` 接口 + `FileSource`/`BundleSource` 双实现从一开始就做,不然后面整片重写。 + +### 10. 结构服从调用方 + +拍平到根目录,调用方少敲一层路径。按来源类型分,不按模型分。 + +### 11. 框架就是约束,约束就是设计 + +引入 freezed、BLoC、go_router 不只是为了功能。是把 ad-hoc 的手写设计放进工业标准框子——框子卡住的地方,就是技术债的真实位置。 + +不要评价框架「现阶段有没有用」。框架的意义是让设计缺陷提前暴露。 + +## 本地开发 + +```bash +flutter run -d linux +flutter run -d chrome +dart analyze lib/ test/ +flutter test +dart run build_runner build # freezed codegen +``` + +## 版本约定 + +- `v0.0.x` — 探索验证阶段 +- `v0.1.0` 起 — 上线推进阶段 diff --git a/src/studio/README.md b/src/studio/README.md new file mode 100644 index 00000000..d7ef47c2 --- /dev/null +++ b/src/studio/README.md @@ -0,0 +1,26 @@ +# qtadmin_studio + +量潮管理后台客户端。 + +## 目录 + +``` +lib/ +├── blocs/ # BLoC 状态管理 +├── models/ # freezed 数据模型 +├── sources/ # 数据源抽象(base + file_source + bundle_source) +├── screens/ # 页面 +├── views/ # 组件 +├── theme.dart # 颜色工具 +├── constants.dart# UI 映射常量 +├── main.dart +└── router.dart # RouteConfig + AppRouter +``` + +## 开发 + +```bash +git config core.hooksPath .githooks # 激活 pre-commit 检查(dart analyze) +flutter test # 运行全部 166 个测试 +dart analyze lib/ test/ # 静态检查 +``` diff --git a/analysis_options.yaml b/src/studio/analysis_options.yaml similarity index 100% rename from analysis_options.yaml rename to src/studio/analysis_options.yaml diff --git a/android/.gitignore b/src/studio/android/.gitignore similarity index 100% rename from android/.gitignore rename to src/studio/android/.gitignore diff --git a/android/app/build.gradle b/src/studio/android/app/build.gradle similarity index 97% rename from android/app/build.gradle rename to src/studio/android/app/build.gradle index 5ce6b945..a41f07e4 100644 --- a/android/app/build.gradle +++ b/src/studio/android/app/build.gradle @@ -44,7 +44,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.quanttide.qtadmin_client_flutter" + applicationId "com.quanttide.qtadmin_studio" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. minSdkVersion flutter.minSdkVersion diff --git a/android/app/src/debug/AndroidManifest.xml b/src/studio/android/app/src/debug/AndroidManifest.xml similarity index 87% rename from android/app/src/debug/AndroidManifest.xml rename to src/studio/android/app/src/debug/AndroidManifest.xml index 3307fb87..9c01b92b 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/src/studio/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,5 @@ + package="com.quanttide.qtadmin_studio"> - + - qtadmin_client_flutter + 量潮管理后台