Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions .github/workflows/jobscout-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ on:
- 'jobscout-web/**'
- 'deploy/jobscout/**'
- 'docker/**'
- 'skills/public/**'
- '.dockerignore'
- '.github/workflows/jobscout-deploy.yml'
push:
branches: [main]
paths:
- '.github/workflows/jobscout-deploy.yml'
- 'deploy/jobscout/**'
- 'docker/docker-compose.jobscout.yaml'
- 'docker/docker-compose.jobscout*.yaml'
- 'docker/nginx/jobscout.conf'
- 'backend/Dockerfile'
- 'backend/app/**'
Expand All @@ -28,6 +30,7 @@ on:
- 'scripts/jobscout*.py'
- 'jobscout-web/**'
- '.dockerignore'
- 'skills/public/**'
permissions:
contents: read
concurrency:
Expand All @@ -36,7 +39,10 @@ concurrency:
jobs:
container-smoke:
runs-on: ubuntu-latest
timeout-minutes: 40
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down Expand Up @@ -94,9 +100,73 @@ jobs:
assert status['needs_setup'] is False
print('Administrator restored into a fresh data volume')
PY
- name: Package the tested application as commit-tagged images
env:
REPOSITORY: ${{ github.repository }}
COMMIT: ${{ github.sha }}
run: |
python scripts/jobscout.py down
prefix="ghcr.io/${REPOSITORY,,}"
tag="sha-${COMMIT}"
echo "JOBSCOUT_RELEASE_PREFIX=${prefix}" >> "$GITHUB_ENV"
echo "JOBSCOUT_RELEASE_TAG=${tag}" >> "$GITHUB_ENV"
docker build -f deploy/jobscout/gateway.Dockerfile --label "org.opencontainers.image.source=https://github.com/${REPOSITORY}" --label "org.opencontainers.image.revision=${COMMIT}" -t "${prefix}-gateway:${tag}" .
docker build -f deploy/jobscout/web.Dockerfile --label "org.opencontainers.image.source=https://github.com/${REPOSITORY}" --label "org.opencontainers.image.revision=${COMMIT}" -t "${prefix}-web:${tag}" .
docker tag jobscout-frontend:latest "${prefix}-capabilities:${tag}"
python - <<'PY'
import os
from pathlib import Path
p = Path('.jobscout/.env')
p.write_text(p.read_text() + '\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_PREFIX=ghcr.io/' + os.environ['REPOSITORY'].lower() + '\nJOBSCOUT_IMAGE_TAG=sha-' + os.environ['COMMIT'] + '\nJOBSCOUT_DATA_VOLUME=jobscout-image-data\n')
PY
- name: Start packaged images and verify no source mounts
run: |
python scripts/jobscout.py up --no-pull
python scripts/jobscout_smoke.py
python - <<'PY'
import importlib.util, json, subprocess, urllib.request
from pathlib import Path
spec = importlib.util.spec_from_file_location('deploy', 'scripts/jobscout.py')
deploy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(deploy)
directory = Path('.jobscout').resolve()
config = json.loads(deploy.compose(directory, ['config', '--format', 'json'], check=True, capture_output=True, text=True).stdout)
services = config['services']
for service in ('gateway', 'frontend', 'nginx'):
assert 'build' not in services[service]
assert {v['target'] for v in services['gateway']['volumes']} == {'/config/config.yaml', '/config/extensions_config.json', '/data'}
assert {v['target'] for v in services['nginx']['volumes']} == {'/usr/share/nginx/jobscout/runtime-config.js'}
deploy.compose(directory, ['exec', '-T', 'gateway', '/app/backend/.venv/bin/python', '-c', "from pathlib import Path; assert list(Path('/app/skills/public').glob('*/SKILL.md'))"], check=True)
for name in ('core', 'api-client', 'tracker-state'):
response = urllib.request.urlopen('http://127.0.0.1:2026/' + name + '.js')
assert response.status == 200 and 'javascript' in response.headers['Content-Type']
print('Packaged assets and public skills ready; no application source bind mounts')
PY
- name: Sign in to GitHub Container Registry
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish tested commit images
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
run: |
for component in gateway capabilities web; do
docker push "${JOBSCOUT_RELEASE_PREFIX}-${component}:${JOBSCOUT_RELEASE_TAG}"
done
echo "Published image tag: \`${JOBSCOUT_RELEASE_TAG}\` (linux/amd64)" >> "$GITHUB_STEP_SUMMARY"
- name: Service diagnostics
if: failure()
run: docker compose --project-name jobscout --env-file .jobscout/.env -f docker/docker-compose.jobscout.yaml logs --tail 80
run: |
python - <<'PY'
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('deploy', 'scripts/jobscout.py')
deploy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(deploy)
deploy.compose(Path('.jobscout').resolve(), ['logs', '--tail', '80'])
PY
- name: Stop disposable services
if: always()
run: python scripts/jobscout.py down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ python scripts/jobscout.py up

打开 **http://localhost:2026**,创建管理员账号即可进入工作台。首次构建会下载依赖并编译上游能力中心;命令会等待健康检查通过后才报告启动成功。

服务器也可以直接拉取 **按完整提交号标记的预构建镜像**,省去现场安装依赖和编译。在 `.jobscout/.env` 设置 `JOBSCOUT_DEPLOY_MODE=images` 与已发布的 `JOBSCOUT_IMAGE_TAG=sha-完整提交号`,再运行同一个 `up` 命令。镜像模式要求 Compose 2.24.4+,当前发布 `linux/amd64`;具体版本获取、访问权限和升级步骤见[镜像部署](docs/jobscout/deployment.md#使用预构建镜像)。

这套入口会为你准备:

- **独立配置目录:** `.jobscout/`,初始化不覆盖原来根目录的本地开发配置。
Expand Down Expand Up @@ -193,6 +195,8 @@ Linux/macOS 将 Python 路径替换为 `backend/.venv/bin/python`,或在 backe

[部署 CI](.github/workflows/jobscout-deploy.yml) 在临时 Linux 实例验证镜像构建、首次初始化、同域认证、持久化与 Chromium 启动;它不代表真实招聘网站和模型服务已经验收。前端测试还覆盖注册策略、连接重试、桌面/移动端、任务并发、取消及历史记录操作。

投递工作台按 **每页 50 条**加载,支持跨页环节筛选;新增记录不会把后续页面的游标挤乱。顶部总数与状态统计仍覆盖全部投递,CSV 导出与批量检查也保持完整范围。请求处理、分页状态、报告渲染与证据展示已拆为独立模块,页面仍无需构建即可运行。实现与性能边界见[可靠性记录](docs/jobscout/reliability.md)。

## 继续了解

| 想了解什么 | 文档 |
Expand Down
5 changes: 5 additions & 0 deletions backend/app/application_tracker/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Application tracker

`pagination.py` returns owner-scoped cursor pages: only page rows load full evidence;
global summaries scan compact state. Reuse MailStore resolution for mail conflicts
and manual stages. Keep the legacy full list for exports/batches; no count claims
based on the current page. Cursor mutation and page/full-list parity tests are offline.

Refresh writes compare revision, identity and checked_at inside BEGIN IMMEDIATE; preserve notification/history atomicity. Manual, batch and scheduled checks share the per-loop refresh lock. Connections close on context exit. Gateway caches store initialization off-loop. CSV export/import share versioned text encoding and bilingual column mapping; never import status history from a CSV.

Stage 7: `scheduling.py` owns owner-scoped settings, atomic per-day reservations and transactional notices. `scheduled_graph.py` accepts only the gated, trusted native scheduler context; never assemble the lead agent here. `allow_model_fallback=False` must avoid model initialization on every tracker branch. Cancellation/failure consumes reserved quota. Portal notices use the check transaction; mail notices share source resolution and the mail transaction. See `docs/jobscout/scheduled-refresh.md`.
Expand Down
35 changes: 23 additions & 12 deletions backend/app/application_tracker/email/store.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Private mail ledger and a derived view; portal history is never overwritten."""

import json
from collections import defaultdict
from datetime import UTC, datetime
from hashlib import sha256

Expand Down Expand Up @@ -41,13 +42,13 @@ def save(self, user_id: str, message: MailMessage, event: MailEvent) -> bool:
if row is None or row["company"] != event.company or row["role"] != event.role or not all(is_evidence_grounded(source, text) for text in (event.company, event.role)):
raise ValueError("Mail application ownership or evidence mismatch")
app = self.tracker._application_from_row(row)
previous = self._decorate([app], self._events(connection, user_id))[0]
previous = self._decorate([app], self._events(connection, user_id, application_ids=[app.id]))[0]
result = connection.execute(
"INSERT OR IGNORE INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)",
(user_id, event.application_id, fingerprint, event.model_dump_json(), event.received_at.astimezone(UTC).isoformat()),
)
if result.rowcount and previous is not None:
current = self._decorate([app], self._events(connection, user_id))[0]
current = self._decorate([app], self._events(connection, user_id, application_ids=[app.id]))[0]
before = (previous.source_summary or {}).get("status", previous.status.value)
after = current.source_summary or {}
if before != "未知" and after.get("source") == "email" and not after.get("conflict") and after["status"] != before:
Expand All @@ -69,26 +70,36 @@ def list_events(self, user_id: str, application_id: int | None = None) -> list[d
def decorate(self, user_id: str, applications: list):
# One bounded query per user listing, not per application.
with self.tracker._connect() as connection:
events = self._events(connection, self.tracker._validated_user_id(user_id))
events = self._events(connection, self.tracker._validated_user_id(user_id), application_ids=[app.id for app in applications])
return self._decorate(applications, events)

@staticmethod
def _events(connection, user_id):
def _events(connection, user_id, *, application_ids=None, compact=False):
if application_ids == []:
return []
# Bind at most one page's IDs; full export keeps the original owner query.
bounded_ids = application_ids if application_ids is not None and len(application_ids) <= 100 else None
predicate = " AND application_id IN (" + ",".join("?" for _ in bounded_ids) + ")" if bounded_ids else ""
projection = "json_object('application_id',application_id,'status',json_extract(event_json,'$.status'),'review_reason',NULL,'received_at',json_extract(event_json,'$.received_at'))" if compact else "event_json"
rows = connection.execute(
"""SELECT id,event_json FROM (
SELECT id,event_json,DENSE_RANK() OVER (PARTITION BY application_id ORDER BY received_at DESC) AS n
FROM jobscout_mail_events WHERE user_id=? AND application_id IS NOT NULL
f"""SELECT id,event_json FROM (
SELECT id,{projection} AS event_json,DENSE_RANK() OVER (PARTITION BY application_id ORDER BY received_at DESC) AS n
FROM jobscout_mail_events WHERE user_id=? AND application_id IS NOT NULL {predicate}
AND json_extract(event_json,'$.review_reason') IS NULL AND json_extract(event_json,'$.status') IS NOT NULL
) WHERE n <= 2 ORDER BY id DESC""",
(user_id,),
(user_id, *(bounded_ids or [])),
).fetchall()
return sorted([{"id": row["id"], **json.loads(row["event_json"])} for row in rows], key=lambda event: (datetime.fromisoformat(event["received_at"]), event["id"]), reverse=True)

@staticmethod
def _decorate(applications, events):
by_application = defaultdict(list)
for event in events:
if event["status"] and not event["review_reason"]:
by_application[event["application_id"]].append(event)
results = []
for app in applications:
candidates = [event for event in events if event["application_id"] == app.id and event["status"] and not event["review_reason"]]
candidates = by_application[app.id]
if not candidates:
results.append(app)
continue
Expand All @@ -108,10 +119,10 @@ def _decorate(applications, events):
"source": "email" if selected else "portal",
"conflict": conflict,
"email_status": status,
"evidence": latest["quote"],
"evidence": latest.get("quote", ""),
"received_at": latest["received_at"],
"event_at": latest["event_at"],
"time_evidence": latest["time_quote"],
"event_at": latest.get("event_at"),
"time_evidence": latest.get("time_quote", ""),
"event_id": latest["id"],
}
results.append(app.model_copy(update={"source_summary": summary}))
Expand Down
77 changes: 77 additions & 0 deletions backend/app/application_tracker/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Bounded full-row pages with compact global status projections.

Counts scan small status fields, never every application's evidence/notes. Mail
resolution stays in MailStore so pagination cannot disagree with single reads.
"""

from collections import Counter
from datetime import datetime

from pydantic import BaseModel

from .email.store import TERMINAL, MailStore
from .models import ApplicationStatus, CheckResult


class SummaryRow(BaseModel):
id: int
status: ApplicationStatus
stage: str
stage_manual: bool
confidence: float
checked_at: datetime | None
check_result: CheckResult | None
source_summary: dict | None = None


def presentation(row):
source = row.source_summary or {}
mail = source.get("source") == "email" and not source.get("conflict")
status = source["status"] if mail else row.status.value
stage = source["status"] if mail and not row.stage_manual else row.stage or status
review = bool(source.get("conflict") or status == "未知" or (not mail and ((row.checked_at and row.confidence < 0.7) or (row.check_result and row.check_result is not CheckResult.SUCCESS))))
if review:
bucket = "review"
elif status == "Offer":
bucket = "offers"
elif status not in TERMINAL:
bucket = "active"
else:
bucket = None
return stage, bucket


def list_page(store, user_id, *, limit=50, before_id=None, stage=None):
user_id = store._validated_user_id(user_id)
if not 1 <= limit <= 100 or (before_id is not None and before_id < 1):
raise ValueError("Invalid page bounds")
with store._connect() as connection:
connection.execute("BEGIN")
rows = connection.execute("SELECT id,status,stage,stage_manual,confidence,checked_at,check_result FROM applications WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
projected = MailStore._decorate([SummaryRow.model_validate(dict(row)) for row in rows], MailStore._events(connection, user_id, compact=True))
summary = {"total": len(projected), "active": 0, "review": 0, "offers": 0}
stages, matching = Counter(), []
for row in projected:
label, bucket = presentation(row)
stages[label] += 1
if bucket:
summary[bucket] += 1
if stage is None or label == stage:
matching.append(row.id)
eligible = [row_id for row_id in matching if before_id is None or row_id < before_id]
ids = eligible[:limit]
items = []
if ids:
placeholders = ",".join("?" for _ in ids)
selected = connection.execute(f"SELECT * FROM applications WHERE user_id=? AND id IN ({placeholders}) ORDER BY id DESC LIMIT ?", (user_id, *ids, limit)).fetchall()
items = MailStore._decorate([store._application_from_row(row) for row in selected], MailStore._events(connection, user_id, application_ids=ids))
has_more = len(eligible) > limit
return {
"items": items,
"total": summary["total"],
"filtered_total": len(matching),
"summary": summary,
"stages": [{"label": label, "count": count} for label, count in stages.items()],
"has_more": has_more,
"next_before_id": ids[-1] if has_more else None,
}
5 changes: 5 additions & 0 deletions backend/app/application_tracker/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,11 @@ def import_applications(
updated += 1
return ImportSummary(inserted=inserted, updated=updated, total=len(applications))

def list_applications_page(self, user_id: str, *, limit=50, before_id=None, stage=None) -> dict:
from .pagination import list_page

return list_page(self, user_id, limit=limit, before_id=before_id, stage=stage)

def list_applications(self, user_id: str) -> list[StoredApplication]:
user_id = self._validated_user_id(user_id)
with self._connect() as connection:
Expand Down
9 changes: 8 additions & 1 deletion backend/app/gateway/routers/jobscout.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from threading import Lock

from anyio import CancelScope
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field, field_validator, model_validator
Expand Down Expand Up @@ -515,6 +515,13 @@ async def list_tracker_applications(request: Request) -> list[StoredApplication]
return await asyncio.to_thread((await _get_tracker_store()).list_applications, user_id)


@router.get("/tracker/applications/page")
@require_permission("runs", "read")
async def list_tracker_applications_page(request: Request, limit: int = Query(50, ge=1, le=100), before_id: int | None = Query(None, ge=1), stage: str | None = Query(None, max_length=60)) -> dict:
del request
return await asyncio.to_thread((await _get_tracker_store()).list_applications_page, get_effective_user_id(), limit=limit, before_id=before_id, stage=stage)


@router.post("/tracker/applications", response_model=StoredApplication)
@require_permission("runs", "create")
async def create_tracker_application(body: TrackerApplicationCreate, request: Request) -> StoredApplication:
Expand Down
Loading
Loading