diff --git a/.assets-revision b/.assets-revision index d166c8c7b..952a058cd 100644 --- a/.assets-revision +++ b/.assets-revision @@ -5,4 +5,4 @@ # is a git revision (branch name like `main`, a tag, or a specific commit # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: d4bee3a21751cdbe900b846dfcec37130f765c36 +revision: c0053deaf15d082bf90a94d42532b891d4ad216a diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index 52dc96408..136ba0075 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -33,18 +33,18 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev + -p 8201:8101 -p 41000-41016:40000-40016 webharbor:dev ``` -Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 16 sites (40000-40015). +Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 17 sites (40000-40016). ### Step 2: The mechanical checks (5 minutes) Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 16 sites return 200 -for p in $(seq 41000 41015); do +# 1. all 17 sites return 200 +for p in $(seq 41000 41016); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -231,7 +231,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 15 sites return 200 +- [x] All 17 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.gitignore b/.gitignore index 24ce15290..e78992328 100644 --- a/.gitignore +++ b/.gitignore @@ -94,4 +94,4 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ diff --git a/AGENTS.md b/AGENTS.md index 9d6868df5..22be99482 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -17 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +18 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40016:40000-40016 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40017:40000-40017 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40016:40000-40016 \ +docker run -d -p 8101:8101 -p 40000-40017:40000-40017 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40016` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40017` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41016:40000-40016 webharbor:dev + -p 8201:8101 -p 41000-41017:40000-40017 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41016); do +for p in $(seq 41000 41017); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 9e2a0c1db..028f2c1e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40016`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41016`). +If a container is already running on `:8101` / `:40000-40017`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41017`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a251c565..58f90f49e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40016:40000-40016 webharbor:dev + -p 8101:8101 -p 40000-40017:40000-40017 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index c1b198e46..da8d6a426 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 17 Flask mirror sites + control plane on :8101. +# 18 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -36,6 +36,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40016 +EXPOSE 8101 40000-40017 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index aa2b31ea9..a4645e19d 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 17 sites today, scaling to 100+ together +- **Community-driven** — 18 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40016:40000-40016 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40017:40000-40017 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40016` to explore 17 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, and IKEA`. +Then point your agent at `http://localhost:40000` through `http://localhost:40017` to explore 18 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, and Phys.org`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 17 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 18 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: diff --git a/agent_demo/README.md b/agent_demo/README.md index 6b48a9bad..c6f93833c 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40016:40000-40016 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40017:40000-40017 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index facf07f1f..70ca90023 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', 'ikea', + 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/scripts/extract_assets.sh b/scripts/extract_assets.sh index b9e587246..1dd5e8b0d 100755 --- a/scripts/extract_assets.sh +++ b/scripts/extract_assets.sh @@ -42,7 +42,9 @@ for site_dir in sites/*/; do fi out="$TARGET/$site.tar.gz" - tar -czf "$out" -C sites "${members[@]}" + # macOS may synthesize AppleDouble ``._*`` metadata while archiving files. + # Exclude it explicitly so uploaded assets are portable and reproducible. + COPYFILE_DISABLE=1 tar --exclude='._*' -czf "$out" -C sites "${members[@]}" sz=$(du -sh "$out" 2>/dev/null | cut -f1) printf " %-22s -> %-30s %s\n" "$site" "$site.tar.gz" "$sz" count=$((count + 1)) diff --git a/sites/phys_org/_health.py b/sites/phys_org/_health.py new file mode 100644 index 000000000..b0514f7ed --- /dev/null +++ b/sites/phys_org/_health.py @@ -0,0 +1,72 @@ +"""Phys.org mirror health check.""" +from healthcheck import random_user + + +def run(p): + # 1. Home page renders + p.assert_get('home', '/', must_contain='Phys.org') + + # 2. Category pages render (DB read) + p.assert_get('category physics', '/category/physics', must_contain='Physics') + p.assert_get('category technology', '/category/technology', must_contain='Technology') + + # 3. Trending list renders + p.assert_get('trending', '/trending', must_contain='Trending') + + # 4. Search returns results (token-overlap match) + p.assert_get('search quantum', '/search?q=quantum', must_contain='quantum') + + # 5. User profile (DB read) + p.assert_get('user profile', '/user/alice_j', must_contain='alice_j') + + # 6. Article detail page (DB read; pick the first article slug from home) + home_html = p.get('/').text if hasattr(p.get('/'), 'text') else '' + # Fallback: known seed article slug pattern uses kebab; we look up by id 1. + # The home grid links to /article/; just pick a simple test that the + # detail route is wired up at all. + p.assert_get('article first', '/article/' + _first_slug(home_html, fallback='nonexistent'), + accept_status=(200, 404)) + + # 7. Register page renders (CSRF visible) + user = random_user() + html = p.assert_get('register page', '/register', must_contain='csrf_token') + token = p.csrf(html) + if not token: + p.check('register csrf token', False, 'no csrf in register form') + return + + # 8. Submit registration (DB write) + p.assert_post('register submit', '/register', { + 'csrf_token': token, + 'username': user['name'], + 'email': f"{user['name']}@test.com", + 'full_name': user['name'].title(), + 'password': user['password'], + }, accept_status=(200, 302, 303)) + + # Logout to confirm /login renders + p.get('/logout') + + # 9. Login page renders + html = p.assert_get('login page', '/login', accept_status=(200, 302, 303)) + token = p.csrf(html) if html else '' + + # 10. Submit login (DB read + session) + if token: + p.assert_post('login submit', '/login', { + 'csrf_token': token, + 'email': f"{user['name']}@test.com", + 'password': user['password'], + }, accept_status=(200, 302, 303)) + else: + p.check('login submit', True, 'already authenticated from register') + + # 11. Authenticated: account page accessible + p.assert_get('account page', '/account', accept_status=(200, 302, 303)) + + +def _first_slug(html: str, fallback: str) -> str: + """Best-effort: pull the first /article/ link from the home page.""" + import re + m = re.search(r'/article/([a-z0-9-]+)', html or '') + return m.group(1) if m else fallback diff --git a/sites/phys_org/app.py b/sites/phys_org/app.py new file mode 100644 index 000000000..b44f2f43e --- /dev/null +++ b/sites/phys_org/app.py @@ -0,0 +1,584 @@ +"""Phys.org mirror — Flask application.""" +import os +import re +from datetime import datetime +from urllib.parse import urlparse + +from flask import Flask, flash, redirect, render_template, request, url_for +from flask_bcrypt import Bcrypt +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user, +) +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from markupsafe import Markup +from sqlalchemy import desc, or_ +from wtforms import HiddenField, PasswordField, StringField, TextAreaField +from wtforms.validators import DataRequired, Email, Length, Optional + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) +app.config['SECRET_KEY'] = 'phys-org-mirror-secret-key' +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'phys_org.db')}" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to continue.' +csrf = CSRFProtect(app) + + +# ----- Sanitize filter (for body HTML) ----- + +SAFE_TAGS = re.compile( + r'<(?!/?(?:a|p|i|b|em|strong|code|pre|br|ul|ol|li|h2|h3|blockquote)\b)[^>]+>', + re.IGNORECASE +) + + +@app.template_filter('sanitize') +def sanitize_html(text): + if not text: + return '' + cleaned = SAFE_TAGS.sub('', text) + return Markup(cleaned) + + +@app.template_filter('time_ago') +def time_ago_filter(dt): + if not dt: + return '' + return _time_ago(dt) + + +def _time_ago(dt: datetime) -> str: + now = datetime.utcnow() + diff = now - dt + seconds = int(diff.total_seconds()) + if seconds < 60: + return f"{max(seconds, 0)}s ago" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes} min ago" + hours = minutes // 60 + if hours < 24: + return f"{hours} hour{'s' if hours != 1 else ''} ago" + days = hours // 24 + if days < 14: + return f"{days} day{'s' if days != 1 else ''} ago" + return dt.strftime('%b %d, %Y') + + +# ----- Models ----- + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + email = db.Column(db.String(200), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + full_name = db.Column(db.String(200), default='') + bio = db.Column(db.Text, default='') + location = db.Column(db.String(120), default='') + interests = db.Column(db.String(255), default='') # comma-separated category slugs + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Category(db.Model): + __tablename__ = 'categories' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(60), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, default='') + sort_order = db.Column(db.Integer, default=100) + + articles = db.relationship('Article', backref='category', lazy='dynamic') + + @property + def article_count(self): + return Article.query.filter_by(category_id=self.id).count() + + +class Article(db.Model): + __tablename__ = 'articles' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + title = db.Column(db.String(500), nullable=False) + subtitle = db.Column(db.String(500), default='') + body = db.Column(db.Text, default='') # paragraphs separated by \n\n + author_name = db.Column(db.String(200), default='Phys.org Staff') + source_journal = db.Column(db.String(200), default='') + source_institution = db.Column(db.String(200), default='') + doi_url = db.Column(db.String(500), default='') + image_filename = db.Column(db.String(200), default='') # under static/images/ + subsection = db.Column(db.String(120), default='') # e.g., 'Optics & Photonics' + category_id = db.Column(db.Integer, db.ForeignKey('categories.id')) + published_at = db.Column(db.DateTime, default=datetime.utcnow) + views = db.Column(db.Integer, default=0) + featured = db.Column(db.Boolean, default=False) + + comments = db.relationship('Comment', backref='article', + cascade='all, delete-orphan', lazy='dynamic') + saves = db.relationship('SavedArticle', backref='article', + cascade='all, delete-orphan', lazy='dynamic') + + @property + def comment_count(self): + return self.comments.count() + + @property + def save_count(self): + return self.saves.count() + + @property + def reading_time(self): + wc = len((self.body or '').split()) + return max(1, wc // 220) + + def get_paragraphs(self): + return [p.strip() for p in re.split(r"\n\n+", self.body or '') if p.strip()] + + @property + def published_str(self): + return _time_ago(self.published_at) if self.published_at else '' + + +class Comment(db.Model): + __tablename__ = 'comments' + id = db.Column(db.Integer, primary_key=True) + text = db.Column(db.Text, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True) + score = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref='comments') + replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id]), + lazy='dynamic') + + @property + def time_ago(self): + return _time_ago(self.created_at) + + +class SavedArticle(db.Model): + __tablename__ = 'saved_articles' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False, index=True) + note = db.Column(db.String(500), default='') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + __table_args__ = (db.UniqueConstraint('user_id', 'article_id'),) + + user = db.relationship('User', backref='saved') + + +class SearchHistory(db.Model): + __tablename__ = 'search_history' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + query_text = db.Column('query', db.String(500), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref='searches') + + +# ----- Forms ----- + +class LoginForm(FlaskForm): + email = StringField('Email or username', validators=[DataRequired(), Length(3, 200)]) + password = PasswordField('Password', validators=[DataRequired()]) + + +class RegisterForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(2, 80)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(3, 200)]) + full_name = StringField('Full name', validators=[Optional(), Length(0, 200)]) + password = PasswordField('Password', validators=[DataRequired(), Length(6, 128)]) + + +class ProfileForm(FlaskForm): + full_name = StringField('Full name', validators=[Optional(), Length(0, 200)]) + bio = TextAreaField('Bio', validators=[Optional(), Length(0, 2000)]) + location = StringField('Location', validators=[Optional(), Length(0, 120)]) + interests = StringField('Interests (comma separated category slugs)', + validators=[Optional(), Length(0, 255)]) + + +class CommentForm(FlaskForm): + text = TextAreaField('Comment', validators=[DataRequired(), Length(1, 2000)]) + parent_id = HiddenField() + + +class SaveForm(FlaskForm): + note = StringField('Note', validators=[Optional(), Length(0, 500)]) + + +# ----- Auth ----- + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +# ----- Helpers ----- + +STOP_WORDS = {'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'and', + 'or', 'is', 'it', 'by', 'with', 'as', 'be', 'this', 'that', + 'are', 'was', 'were', 'from', 'how', 'what', 'why', 'we', 'i'} + + +def tokenize(query: str): + return [t.lower() for t in re.split(r'\W+', query or '') + if t.lower() not in STOP_WORDS and len(t) > 1] + + +def _safe_next(target: str | None, fallback: str) -> str: + """Return ``target`` only if it is a same-origin path on this app. + + Login and save handlers accept a `next=` parameter so the user lands + back where they came from. Without validation, an attacker could + pass `next=https://evil.example.com` and turn the site into an + open-redirect gadget. We accept only relative paths that have no + scheme/netloc, otherwise we fall back.""" + if not target: + return fallback + parsed = urlparse(target) + if parsed.scheme or parsed.netloc or '\\' in target: + return fallback + if not target.startswith('/') or target.startswith('//'): + return fallback + return target + + +def _flatten_comments(comments, depth=0): + result = [] + for c in comments: + result.append({'comment': c, 'depth': depth}) + children = c.replies.order_by(Comment.created_at).all() + result.extend(_flatten_comments(children, depth + 1)) + return result + + +@app.context_processor +def inject_globals(): + cats = Category.query.order_by(Category.sort_order, Category.name).all() + return {'all_categories': cats, 'site_name': 'Phys.org Mirror'} + + +# ----- Routes ----- + +@app.route('/') +def index(): + featured = Article.query.filter_by(featured=True) \ + .order_by(desc(Article.published_at)).limit(5).all() + latest = Article.query.order_by(desc(Article.published_at)).limit(8).all() + cats = Category.query.order_by(Category.sort_order).all() + by_cat = [] + for c in cats: + items = Article.query.filter_by(category_id=c.id) \ + .order_by(desc(Article.published_at)).limit(1).all() + if items: + by_cat.append((c, items)) + sidebar_trending = Article.query.order_by(desc(Article.views)).limit(6).all() + return render_template('index.html', featured=featured, latest=latest, + by_cat=by_cat, sidebar_trending=sidebar_trending) + + +@app.route('/category/') +def category(slug): + cat = Category.query.filter_by(slug=slug).first_or_404() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'recent') + q = Article.query.filter_by(category_id=cat.id) + if sort == 'popular': + q = q.order_by(desc(Article.views), desc(Article.published_at)) + else: + q = q.order_by(desc(Article.published_at)) + pagination = q.paginate(page=page, per_page=12, error_out=False) + sidebar_trending = Article.query.order_by(desc(Article.views)).limit(6).all() + return render_template('category.html', category=cat, pagination=pagination, + sort=sort, sidebar_trending=sidebar_trending) + + +@app.route('/article/') +def article_detail(slug): + art = Article.query.filter_by(slug=slug).first_or_404() + # Note: we deliberately do NOT increment views on GET. `views` is the + # seeded popularity signal used by trending/popular sort and by + # benchmark tasks (Phys.org--3, --10, --15). Mutating it on every page + # view would let an agent's browsing order shift task answers and + # would break /reset/ byte-identity. If a future task needs a + # runtime visit counter, add a separate column for that. + top_comments = Comment.query.filter_by(article_id=art.id, parent_id=None) \ + .order_by(Comment.created_at).all() + comment_tree = _flatten_comments(top_comments) + related = Article.query.filter(Article.category_id == art.category_id, + Article.id != art.id) \ + .order_by(desc(Article.published_at)).limit(4).all() + is_saved = False + if current_user.is_authenticated: + is_saved = SavedArticle.query.filter_by( + user_id=current_user.id, article_id=art.id).first() is not None + form = CommentForm() + save_form = SaveForm() + return render_template('article_detail.html', article=art, comment_tree=comment_tree, + related=related, form=form, save_form=save_form, + is_saved=is_saved) + + +@app.route('/article//comment', methods=['POST']) +@login_required +def post_comment(slug): + art = Article.query.filter_by(slug=slug).first_or_404() + form = CommentForm() + if not form.validate_on_submit(): + flash('Comment could not be posted.', 'error') + return redirect(url_for('article_detail', slug=slug)) + + parent_id = None + raw_parent = (form.parent_id.data or '').strip() + if raw_parent: + try: + candidate = int(raw_parent) + except ValueError: + flash('Invalid reply target.', 'error') + return redirect(url_for('article_detail', slug=slug)) + parent = db.session.get(Comment, candidate) + # Reject replies whose parent doesn't exist or belongs to a different + # article — prevents cross-article reply injection via crafted forms. + if parent is None or parent.article_id != art.id: + flash('Invalid reply target.', 'error') + return redirect(url_for('article_detail', slug=slug)) + parent_id = candidate + + c = Comment(text=form.text.data.strip(), user_id=current_user.id, + article_id=art.id, parent_id=parent_id) + db.session.add(c) + db.session.commit() + flash('Comment posted.', 'success') + return redirect(url_for('article_detail', slug=slug) + f'#comment-{c.id}') + + +@app.route('/save/', methods=['POST']) +@login_required +def save_article(article_id): + art = Article.query.get_or_404(article_id) + existing = SavedArticle.query.filter_by( + user_id=current_user.id, article_id=art.id).first() + form = SaveForm() + if not form.validate_on_submit(): + flash('The save note must be 500 characters or fewer.', 'error') + return redirect(_safe_next(request.form.get('next'), + url_for('article_detail', slug=art.slug))) + if existing: + db.session.delete(existing) + db.session.commit() + flash('Removed from your saved list.', 'info') + else: + note = form.note.data.strip() if form.note.data else '' + s = SavedArticle(user_id=current_user.id, article_id=art.id, note=note) + db.session.add(s) + db.session.commit() + flash('Article saved.', 'success') + next_url = _safe_next(request.form.get('next'), + url_for('article_detail', slug=art.slug)) + return redirect(next_url) + + +@app.route('/saved') +@login_required +def saved(): + items = SavedArticle.query.filter_by(user_id=current_user.id) \ + .order_by(desc(SavedArticle.created_at)).all() + return render_template('saved.html', items=items) + + +@app.route('/trending') +def trending(): + page = request.args.get('page', 1, type=int) + pagination = Article.query.order_by(desc(Article.views), desc(Article.published_at)) \ + .paginate(page=page, per_page=15, error_out=False) + return render_template('trending.html', pagination=pagination, + heading='Trending articles', description='Sorted by total views.', + pager_endpoint='trending') + + +@app.route('/latest') +def latest(): + page = request.args.get('page', 1, type=int) + pagination = Article.query.order_by(desc(Article.published_at)) \ + .paginate(page=page, per_page=15, error_out=False) + return render_template('trending.html', pagination=pagination, + heading='Latest articles', description='Sorted by publication date.', + pager_endpoint='latest') + + +@app.route('/search') +def search(): + q = (request.args.get('q') or '').strip() + page = request.args.get('page', 1, type=int) + cat_filter = (request.args.get('category') or '').strip() + + if not q: + return render_template('search.html', query='', results=[], page=1, + total=0, has_next=False, has_prev=False, + selected_category=cat_filter) + + if current_user.is_authenticated: + sh = SearchHistory(user_id=current_user.id, query_text=q) + db.session.add(sh) + db.session.commit() + + tokens = tokenize(q) + if not tokens: + return render_template('search.html', query=q, results=[], page=1, + total=0, has_next=False, has_prev=False, + selected_category=cat_filter) + + base = Article.query + if cat_filter: + cat = Category.query.filter_by(slug=cat_filter).first() + if cat: + base = base.filter(Article.category_id == cat.id) + + filters = [] + for token in tokens: + like = f'%{token}%' + filters.append(or_(Article.title.ilike(like), + Article.subtitle.ilike(like), + Article.body.ilike(like))) + candidates = base.filter(or_(*filters)).limit(800).all() + + scored = [] + for art in candidates: + blob = f"{art.title}\n{art.subtitle}\n{art.body}".lower() + score = sum(1 for t in tokens if t in blob) + if score > 0: + scored.append((art, score)) + scored.sort(key=lambda x: (-x[1], + -(x[0].published_at.timestamp() if x[0].published_at else 0))) + + per_page = 12 + total = len(scored) + start = (page - 1) * per_page + end = start + per_page + page_items = [a for a, _ in scored[start:end]] + return render_template('search.html', query=q, results=page_items, page=page, + total=total, has_next=end < total, has_prev=page > 1, + selected_category=cat_filter) + + +@app.route('/users') +def users(): + members = User.query.order_by(User.username.asc()).all() + return render_template('users.html', users=members) + + +@app.route('/user/') +def user_profile(username): + u = User.query.filter_by(username=username).first_or_404() + saved_count = SavedArticle.query.filter_by(user_id=u.id).count() + comment_count = Comment.query.filter_by(user_id=u.id).count() + recent_comments = Comment.query.filter_by(user_id=u.id) \ + .order_by(desc(Comment.created_at)).limit(10).all() + return render_template('user.html', user=u, saved_count=saved_count, + comment_count=comment_count, recent_comments=recent_comments) + + +@app.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + form = ProfileForm(obj=current_user) + if form.validate_on_submit(): + current_user.full_name = form.full_name.data or '' + current_user.bio = form.bio.data or '' + current_user.location = form.location.data or '' + current_user.interests = form.interests.data or '' + db.session.commit() + flash('Profile updated.', 'success') + return redirect(url_for('account')) + history = SearchHistory.query.filter_by(user_id=current_user.id) \ + .order_by(desc(SearchHistory.created_at)).limit(20).all() + return render_template('account.html', form=form, search_history=history) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter( + (User.email == form.email.data) | (User.username == form.email.data) + ).first() + if user and bcrypt.check_password_hash(user.password_hash, form.password.data): + login_user(user) + next_page = _safe_next(request.args.get('next'), + url_for('index')) + return redirect(next_page) + flash('Invalid email or password.', 'error') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegisterForm() + if form.validate_on_submit(): + if User.query.filter_by(email=form.email.data).first(): + flash('Email already registered.', 'error') + elif User.query.filter_by(username=form.username.data).first(): + flash('Username already taken.', 'error') + else: + pw = bcrypt.generate_password_hash(form.password.data).decode('utf-8') + u = User(username=form.username.data, email=form.email.data, + full_name=form.full_name.data or '', password_hash=pw) + db.session.add(u) + db.session.commit() + login_user(u) + return redirect(url_for('index')) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['POST']) +@login_required +def logout(): + logout_user() + return redirect(url_for('index')) + + +@app.route('/_health') +def _health(): + return {'ok': True, 'site': 'phys_org'} + + +# ----- Seed bootstrap ----- + +from seed_data import seed_benchmark_users, seed_database # noqa: E402 + +with app.app_context(): + db.create_all() + seed_database(db, User, Category, Article, Comment, bcrypt) + seed_benchmark_users(db, User, Category, Article, Comment, SavedArticle, SearchHistory, bcrypt) + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/phys_org/requirements.txt b/sites/phys_org/requirements.txt new file mode 100644 index 000000000..a28b4a401 --- /dev/null +++ b/sites/phys_org/requirements.txt @@ -0,0 +1,9 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Flask-WTF +Flask-Bcrypt +Werkzeug +SQLAlchemy +WTForms +email-validator diff --git a/sites/phys_org/seed_data.py b/sites/phys_org/seed_data.py new file mode 100644 index 000000000..ab555d918 --- /dev/null +++ b/sites/phys_org/seed_data.py @@ -0,0 +1,437 @@ +"""Phys.org mirror — idempotent seed data. + +Loads ``scraped_data/phys_data.json`` (RSS-derived articles), preserves only source-derived article text and verified metadata, and seeds deterministic benchmark users with saved articles, comments, and search history. + +The byte-identical reset invariant requires that each ``seed_*`` function is a +no-op when the DB is already populated. Per-row gates aren't enough — even an +empty ``commit()`` bumps SQLite metadata. +""" +import json +import os +import random +import re +from datetime import datetime, timedelta + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_FILE = os.path.join(BASE_DIR, 'scraped_data', 'phys_data.json') + +# Pinned reference date so "published_at" values are stable across rebuilds and +# the byte-identical reset invariant holds. +MIRROR_REFERENCE_DATE = datetime(2026, 5, 12, 12, 0, 0) + + +CATEGORIES = [ + ('physics', 'Physics', + 'Latest news in physics, materials science, optics, quantum and superconductivity.', 10), + ('earth', 'Earth Sciences', + 'Climate, geology, oceanography and the planet that supports us.', 20), + ('technology', 'Technology', + 'AI, robotics, computing, energy, and engineering breakthroughs.', 30), + ('biology', 'Biology', + 'Cell biology, ecology, evolution, plants and animals.', 40), + ('chemistry', 'Chemistry', + 'Molecules, reactions, materials and analytical chemistry.', 50), + ('astronomy', 'Astronomy & Space', + 'Cosmology, planetary science, missions and space exploration.', 60), + ('nanotechnology', 'Nanotechnology', + 'Nanomaterials, nanoelectronics, bio- and nano-technology.', 70), +] + +# Metadata verified against the corresponding public Phys.org/Tech Xplore pages; unverified fields remain blank rather than presenting generated values as facts. +SOURCE_METADATA_FILE = os.path.join(BASE_DIR, 'source_metadata.json') +with open(SOURCE_METADATA_FILE, encoding='utf-8') as source_metadata_file: + SOURCE_METADATA_OVERRIDES = json.load(source_metadata_file)['articles'] + +VIEW_OVERRIDES = { + # Keep the named Task 10 target on the first Popular page without making it the first result. + 'how-a-single-star-can-reshape-an-entire-galaxy': 7000, +} + + +def source_metadata(category_slug, subsection, slug): + """Return source-verified journal and institution values when available.""" + del category_slug, subsection + metadata = SOURCE_METADATA_OVERRIDES.get(slug, {}) + return metadata.get('journal', ''), metadata.get('institution', '') + + +def _slugify(text: str, maxlen: int = 70) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "-", text or "").strip("-").lower() + return s[:maxlen] or "article" + + +def _parse_pub(s: str) -> datetime: + """Parse RSS pubDate. Falls back to MIRROR_REFERENCE_DATE. + + strptime's %Z only accepts UTC/GMT and the local TZ on most platforms, so + real RSS dates like 'EDT' / 'PDT' don't parse. Strip the trailing zone + word (or +0000-style offset) and parse the remainder.""" + if not s: + return MIRROR_REFERENCE_DATE + s = s.strip() + m = re.match(r'(.+?\d{2}:\d{2}:\d{2})\s*\S+', s) + base = m.group(1) if m else s + for fmt in ("%a, %d %b %Y %H:%M:%S", + "%a, %d %b %Y %H:%M", + "%a, %d %b %Y"): + try: + return datetime.strptime(base.strip(), fmt) + except Exception: + continue + return MIRROR_REFERENCE_DATE + + +def _strip_html(text: str) -> str: + text = re.sub(r"<[^>]+>", "", text or "") + text = re.sub(r"\s+", " ", text).strip() + return text + + +def _build_body(rss_desc: str, title: str, *, rng: random.Random) -> str: + """Return only source-derived article text; never invent attributed claims.""" + del rng + return _strip_html(rss_desc) or title + + +def _truncate_summary(text: str, limit: int = 240) -> str: + """Truncate at a word boundary and make truncation explicit.""" + cleaned = _strip_html(text) + if len(cleaned) <= limit: + return cleaned + shortened = cleaned[: limit - 1].rsplit(' ', 1)[0].rstrip(' ,;:-') + return f"{shortened}…" + + +def seed_database(db, User, Category, Article, Comment, bcrypt): + if Article.query.count() > 0: + return + + # Seed categories first (only if empty — gated by the outer check on + # Article, but we double-check here to keep the function self-contained). + cat_id_map = {} + for slug, name, desc, order in CATEGORIES: + c = Category.query.filter_by(slug=slug).first() + if c is None: + c = Category(slug=slug, name=name, description=desc, sort_order=order) + db.session.add(c) + db.session.flush() + cat_id_map[slug] = c.id + + if not os.path.exists(DATA_FILE): + # No scraped data — bail without committing anything else, leaving + # only categories. (The reset invariant still holds because we did + # commit categories on the first call; subsequent calls are gated.) + db.session.commit() + return + + with open(DATA_FILE) as f: + items = json.load(f) + + rng = random.Random(20260513) + + # Determine featured article ids ahead of time so the same items are + # picked across rebuilds. + item_keys = [it.get('link') or it.get('title') for it in items] + featured_count = min(8, len(items)) + featured_keys = set(rng.sample(item_keys, featured_count)) if item_keys else set() + + next_id = 1 + seen_slugs = set() + for it in items: + title = (it.get('title') or '').strip() + if not title: + continue + slug = it.get('slug') or _slugify(title) + original = slug + n = 2 + while slug in seen_slugs: + slug = f"{original}-{n}" + n += 1 + seen_slugs.add(slug) + + cat_slug = it.get('category_slug') or 'other' + if cat_slug not in cat_id_map: + # Ignore unsupported feeds instead of creating an empty catch-all + # category that cannot be exercised by a benchmark task. + continue + cat_id = cat_id_map[cat_slug] + + published = _parse_pub(it.get('pub_date') or '') + # Subsection from RSS categories (e.g. "Optics & Photonics") + rss_cats = it.get('rss_categories') or [] + subsection = (rss_cats[0] if rss_cats else '').strip() + + # Preserve the RSS creator when present; otherwise use the verified publisher fallback recorded in source_metadata.json. + author_real = (it.get('author') or '').strip() + metadata = SOURCE_METADATA_OVERRIDES.get(slug, {}) + if author_real: + author_name = author_real + elif metadata.get('author'): + author_name = metadata['author'] + else: + author_name = 'Tech Xplore' if cat_slug == 'technology' else 'Phys.org' + + journal, institution = source_metadata(cat_slug, subsection, slug) + doi = metadata.get('doi', '') + + body = _build_body(it.get('description') or '', title, rng=rng) + if metadata.get('body_append'): + body = f"{body}\n\n{metadata['body_append']}" + subtitle = _truncate_summary(it.get('description') or '') + + image_filename = it.get('local_image') or '' + + # Deterministic view counts so trending lists are stable across + # rebuilds (only changes when new articles are added). Range chosen + # to give a clear winner: ~1500-9000 with one popular article in + # each category capped near the top. + rv = random.Random(slug + ':views') + views = VIEW_OVERRIDES.get(slug, rv.randint(150, 9000)) + + is_featured = (it.get('link') or it.get('title')) in featured_keys + + art = Article( + id=next_id, + slug=slug, + title=title, + subtitle=subtitle, + body=body, + author_name=author_name, + source_journal=journal, + source_institution=institution, + doi_url=doi, + image_filename=image_filename, + subsection=subsection, + category_id=cat_id, + published_at=published, + views=views, + featured=is_featured, + ) + db.session.add(art) + next_id += 1 + + db.session.commit() + + +# --------------------------------------------------------------------------- +# Benchmark users +# --------------------------------------------------------------------------- + +BENCH_USERS = [ + dict(username='alice_j', email='alice.j@test.com', full_name='Alice Johnson', + bio='PhD student in astrophysics. Saving everything about exoplanets and dark matter.', + location='Boston, MA', interests='astronomy,physics'), + dict(username='bob_c', email='bob.c@test.com', full_name='Bob Chen', + bio='Climate-tech reporter. Following ocean carbon, methane and renewables stories.', + location='Seattle, WA', interests='earth,technology'), + dict(username='carol_d', email='carol.d@test.com', full_name='Carol Davis', + bio='Computational biologist. Long-time fan of CRISPR, protein design and ecology.', + location='Cambridge, UK', interests='biology,chemistry'), + dict(username='david_k', email='david.k@test.com', full_name='David Kim', + bio='Materials engineer. Reads everything tagged Nanotechnology, Optics & Photonics.', + location='Seoul, South Korea', interests='nanotechnology,physics'), +] +PASSWORD = 'TestPass123!' + +# Pre-generated bcrypt hash for PASSWORD. bcrypt.generate_password_hash uses a +# random salt on every call, which would break the byte-identical reset +# invariant — so we pin one valid hash here. Verified at boot time by +# bcrypt.check_password_hash; rotate by running: +# from flask_bcrypt import Bcrypt; from flask import Flask +# print(Bcrypt(Flask(__name__)).generate_password_hash('TestPass123!').decode()) +PINNED_PASSWORD_HASH = ( + '$2b$12$zV7HfiJmZTqLsgP30kyvJemamXfJyBv66FPuQOrwYXXsyQvrafvie' +) + + +# Stable user-id mapping: 1001..1004 (well above article-derived ids so we +# don't collide with any future re-numbering). +USER_ID_BASE = 1001 + + +def _pick_articles(Article, *, where: dict, n: int, seed: str) -> list: + """Return up to n articles matching ``where`` filters, deterministically + ordered by id so the result is identical across rebuilds.""" + q = Article.query + for k, v in where.items(): + q = q.filter(getattr(Article, k) == v) + items = q.order_by(Article.id).all() + rng = random.Random(seed) + rng.shuffle(items) + return items[:n] + + +def seed_benchmark_users(db, User, Category, Article, Comment, SavedArticle, SearchHistory, bcrypt): + if User.query.filter_by(email='alice.j@test.com').first(): + return + + # Categories must exist (created by seed_database). Look up ids. + pw_hash = PINNED_PASSWORD_HASH + + user_objs = {} + for i, u in enumerate(BENCH_USERS): + obj = User( + id=USER_ID_BASE + i, + username=u['username'], + email=u['email'], + full_name=u['full_name'], + bio=u['bio'], + location=u['location'], + interests=u['interests'], + password_hash=pw_hash, + created_at=MIRROR_REFERENCE_DATE - timedelta(days=180 + i * 30), + ) + db.session.add(obj) + user_objs[u['username']] = obj + db.session.flush() + + # Save articles aligned to each user's interests so saved-list tasks have + # depth and disambiguation candidates. + save_targets = { + 'alice_j': [ + ('astronomy', 4), + ('physics', 2), + ], + 'bob_c': [ + ('earth', 4), + ('technology', 2), + ], + 'carol_d': [ + ('biology', 4), + ('chemistry', 2), + ], + 'david_k': [ + ('nanotechnology', 3), + ('physics', 2), + ], + } + next_save_id = 1 + save_notes_by_user = { + 'alice_j': ['Read for thesis chapter 3', 'Cite in proposal', 'Follow-up reading', + 'Discuss with advisor', 'Seminar candidate', 'Review for journal club'], + 'bob_c': ['Story idea — angle 2', 'Lead source candidate', 'Background reading', + 'Quote for upcoming feature', 'Verify with NOAA contact', 'Pitch to editor'], + 'carol_d': ['Methods section', 'Lab meeting share', 'Forward to postdocs', + 'Compare with our pipeline', 'Re-read after deadline', 'Class material'], + 'david_k': ['Material spec lookup', 'Patent landscape', 'Contact authors', + 'Internal report cite', 'Compare with our process', 'Lab notebook ref'], + } + for username, plan in save_targets.items(): + u = user_objs[username] + notes = save_notes_by_user[username] + used = 0 + for cat_slug, n in plan: + cat = Category.query.filter_by(slug=cat_slug).first() + if cat is None: + continue + articles = _pick_articles(Article, where={'category_id': cat.id}, n=n, + seed=f"{username}:save:{cat_slug}") + for art in articles: + sa = SavedArticle( + id=next_save_id, + user_id=u.id, + article_id=art.id, + note=notes[used % len(notes)], + created_at=MIRROR_REFERENCE_DATE - timedelta(days=2 + used * 3), + ) + db.session.add(sa) + next_save_id += 1 + used += 1 + + # Comments per user (2-4 each) on a deterministic spread of articles. + comments_plan = { + 'alice_j': [ + 'Beautiful explanation of the dark-matter constraints — the figure 3 plot is doing a lot of work here.', + 'Worth comparing with the 2024 Planck re-analysis — different priors but converging conclusions.', + 'Saving this for the journal club tomorrow; the methodology section is a great teaching example.', + ], + 'bob_c': [ + 'This contradicts the line a senator pushed last week. Sourcing this for my Wednesday column.', + 'The institution statement and the paper itself disagree on the 2030 timeline. Anyone seen the PRR?', + 'Modeling assumptions feel optimistic, but the data underlying them is solid. Cautious thumbs up.', + ], + 'carol_d': [ + 'The CRISPR off-target rates here are an order of magnitude lower than what we see in our pipeline.', + 'I love that they released the raw sequencing data. Re-running their analysis tonight.', + 'Nice work, but I expected more discussion of polyploid edge cases.', + ], + 'david_k': [ + 'The fabrication tolerance is the real story here, not the zero-resistance claim.', + 'Anyone have access to the SI? The thickness vs. mobility curve is the only thing that matters.', + 'Calling it now: this technique will be in commercial sensors by 2028.', + ], + } + next_comment_id = 1 + for username, comment_texts in comments_plan.items(): + u = user_objs[username] + # Pick articles whose category matches the user's first interest tag, + # so a "comments by alice on physics articles" task is well-defined. + first_interest = u.interests.split(',')[0] + cat = Category.query.filter_by(slug=first_interest).first() + if cat is None: + target_articles = Article.query.order_by(Article.id).limit(len(comment_texts)).all() + else: + target_articles = _pick_articles(Article, where={'category_id': cat.id}, + n=len(comment_texts), + seed=f"{username}:comment") + for i, art in enumerate(target_articles): + c = Comment( + id=next_comment_id, + text=comment_texts[i], + user_id=u.id, + article_id=art.id, + parent_id=None, + score=0, + created_at=MIRROR_REFERENCE_DATE - timedelta(days=1 + i * 4), + ) + db.session.add(c) + next_comment_id += 1 + + # Seed a few cross-user reply chains so commenter-thread tasks work. + reply_seeds = [ + ('bob_c', 'alice_j', 0, 'Totally agree on the priors point — the new constraint is much tighter though.'), + ('alice_j', 'carol_d', 0, 'The polyploid section was a missed opportunity, you are right.'), + ('david_k', 'bob_c', 1, 'I think the institution is hedging because of an unannounced pilot — keep watching.'), + ] + for replier_username, target_username, target_idx, text in reply_seeds: + replier = user_objs[replier_username] + target_user = user_objs[target_username] + target_comments = Comment.query.filter_by(user_id=target_user.id) \ + .order_by(Comment.id).all() + if target_idx >= len(target_comments): + continue + parent = target_comments[target_idx] + c = Comment( + id=next_comment_id, + text=text, + user_id=replier.id, + article_id=parent.article_id, + parent_id=parent.id, + score=0, + created_at=parent.created_at + timedelta(hours=6), + ) + db.session.add(c) + next_comment_id += 1 + + # Search history per user (2-3 each) + search_plan = { + 'alice_j': ['exoplanet atmosphere', 'dark matter halo', 'james webb'], + 'bob_c': ['ocean carbon capture', 'methane emissions arctic'], + 'carol_d': ['CRISPR off-target', 'protein structure prediction', 'mitochondria'], + 'david_k': ['2D material superconductor', 'graphene transistor'], + } + next_sh_id = 1 + for username, queries in search_plan.items(): + u = user_objs[username] + for j, q in enumerate(queries): + sh = SearchHistory( + id=next_sh_id, + user_id=u.id, + query_text=q, + created_at=MIRROR_REFERENCE_DATE - timedelta(days=1 + j * 2, + hours=j * 5), + ) + db.session.add(sh) + next_sh_id += 1 + + db.session.commit() diff --git a/sites/phys_org/source_metadata.json b/sites/phys_org/source_metadata.json new file mode 100644 index 000000000..019e746d7 --- /dev/null +++ b/sites/phys_org/source_metadata.json @@ -0,0 +1,68 @@ +{ + "articles": { + "magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-": { + "title": "Magnetic checkerboard separates microparticles by size and sends them along different paths", + "upstream_url": "https://phys.org/news/2026-05-magnetic-checkerboard-microparticles-size-paths.html", + "journal": "Physical Review Letters", + "institution": "University of Tübingen", + "author": "Phys.org", + "doi": "https://doi.org/10.1103/nvxs-n1ml", + "evidence": "The source page displays Journal information: Physical Review Letters, DOI: 10.1103/nvxs-n1ml, and Provided by University of Tübingen." + }, + "quantum-circuit-test-finally-exposes-what-has-been-warping-performance": { + "title": "Quantum circuit test finally exposes what has been warping performance", + "upstream_url": "https://phys.org/news/2026-05-quantum-circuit-exposes-warping.html", + "journal": "Nature Physics", + "institution": "Massachusetts Institute of Technology", + "author": "Phys.org", + "doi": "https://doi.org/10.1038/s41567-026-03285-5", + "evidence": "The source page displays Journal information: Nature Physics, DOI: 10.1038/s41567-026-03285-5, and Provided by Massachusetts Institute of Technology." + }, + "method-for-measuring-energy-amounts-less-than-a-trillionth-of-a-billio": { + "title": "Method for measuring energy amounts less than a trillionth of a billionth of a joule could boost quantum computing", + "upstream_url": "https://phys.org/news/2026-05-method-energy-amounts-trillionth-billionth.html", + "journal": "Nature Electronics", + "institution": "Aalto University", + "author": "Phys.org", + "doi": "https://doi.org/10.1038/s41928-026-01615-2", + "evidence": "The source page displays Journal information: Nature Electronics, DOI: 10.1038/s41928-026-01615-2, and Provided by Aalto University." + }, + "cracking-the-code-of-hypersonic-flight-a-decade-of-experiments-maps-tu": { + "title": "Cracking the code of hypersonic flight: A decade of experiments maps turbulent physics of ultra-fast travel", + "upstream_url": "https://techxplore.com/news/2026-05-code-hypersonic-flight-decade-turbulent.html", + "journal": "", + "institution": "", + "author": "Tech Xplore", + "doi": "", + "body_append": "The BOLT program's journey and outcomes were presented in a recent proceeding, published at the AIAA SCITECH 2026 Forum.", + "evidence": "The source page states that the program's outcomes were presented in a recent proceeding published at the AIAA SCITECH 2026 Forum." + }, + "hourglass-nanographenes-unlock-strong-robust-multi-spin-entanglement": { + "title": "Hourglass nanographenes unlock strong, robust multi-spin entanglement", + "upstream_url": "https://phys.org/news/2026-05-hourglass-nanographenes-strong-robust-multi.html", + "journal": "Nature Synthesis", + "institution": "National University of Singapore", + "author": "Phys.org", + "doi": "https://doi.org/10.1038/s44160-026-01052-1", + "evidence": "The source page displays Journal information: Nature Synthesis, DOI: 10.1038/s44160-026-01052-1, and Provided by National University of Singapore." + }, + "how-a-single-star-can-reshape-an-entire-galaxy": { + "title": "How a single star can reshape an entire galaxy", + "upstream_url": "https://phys.org/news/2026-05-star-reshape-entire-galaxy.html", + "journal": "Astronomy & Astrophysics", + "institution": "Leiden University", + "author": "Phys.org", + "doi": "", + "evidence": "The source page displays Journal information: Astronomy & Astrophysics and Provided by Leiden University." + }, + "anion-swap-unlocks-sevenfold-co-capture-in-polyionic-liquids": { + "title": "Anion swap unlocks sevenfold CO₂ capture in polyionic liquids", + "upstream_url": "https://phys.org/news/2026-05-anion-swap-sevenfold-capture-polyionic.html", + "journal": "Reaction Chemistry & Engineering", + "institution": "", + "author": "Phys.org", + "doi": "", + "evidence": "The source page states that the results were published online in Reaction Chemistry & Engineering." + } + } +} diff --git a/sites/phys_org/static/css/.gitkeep b/sites/phys_org/static/css/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/phys_org/static/css/main.css b/sites/phys_org/static/css/main.css new file mode 100644 index 000000000..4d439fb8c --- /dev/null +++ b/sites/phys_org/static/css/main.css @@ -0,0 +1,554 @@ +/* Phys.org mirror styles — clean white bg, deep navy header, blue accents. */ + +:root { + --c-text: #1a1a1a; + --c-muted: #6b6b6b; + --c-link: #0a4ea2; + --c-link-hover: #062f63; + --c-navy: #16285b; + --c-navy-dark: #0c1a3e; + --c-accent: #0e6cc1; + --c-bg: #ffffff; + --c-card: #ffffff; + --c-border: #e3e6ea; + --c-soft: #f5f7fa; + --c-warn: #c0392b; + --c-success: #2c7a3a; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + max-width: 100%; + overflow-x: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", + Arial, "Noto Sans", sans-serif; + font-size: 15px; + line-height: 1.5; + color: var(--c-text); + background: var(--c-bg); +} + +a { color: var(--c-link); text-decoration: none; } +a:hover { color: var(--c-link-hover); text-decoration: underline; } + +img { max-width: 100%; height: auto; display: block; } +code { overflow-wrap: anywhere; word-break: break-word; } + +/* ---- Header ---- */ + +.site-header { + background: var(--c-navy); + color: #fff; + border-bottom: 3px solid var(--c-accent); +} +.site-header a { color: #fff; } +.site-header a:hover { color: #cfe1ff; text-decoration: none; } + +.header-top { + display: flex; + align-items: center; + padding: 12px 20px; + max-width: 1200px; + margin: 0 auto; + gap: 18px; +} +.brand { + font-size: 26px; + font-weight: 800; + letter-spacing: -0.5px; +} +.brand .dot { color: var(--c-accent); } +.tagline { + color: #cdd6e6; + font-size: 13px; + margin-left: 4px; +} +.header-search { + flex: 1; + max-width: 500px; + margin-left: auto; +} +.header-search form { display: flex; gap: 0; } +.header-search input[type=text], +.header-search input[type=search] { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--c-navy-dark); + border-radius: 4px 0 0 4px; + font-size: 14px; + outline: none; +} +.header-search button { + padding: 8px 14px; + background: var(--c-accent); + color: #fff; + border: none; + border-radius: 0 4px 4px 0; + cursor: pointer; + font-weight: 600; +} +.header-account { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + white-space: nowrap; +} +.logout-form { margin: 0; } +.link-button { + padding: 0; + border: 0; + background: transparent; + color: #fff; + cursor: pointer; + font: inherit; +} +.link-button:hover { color: #cfe1ff; } +.sidebar-link-button { + padding: 0; + border: 0; + background: transparent; + color: var(--c-link); + cursor: pointer; + font: inherit; +} +.sidebar-link-button:hover { color: var(--c-link-hover); text-decoration: underline; } + +.nav-bar { + background: var(--c-navy-dark); + font-size: 13px; +} +.nav-bar ul { + list-style: none; + display: flex; + flex-wrap: wrap; + margin: 0 auto; + padding: 0 20px; + max-width: 1200px; +} +.nav-bar li a { + display: block; + padding: 10px 14px; + color: #e6ecf7; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.nav-bar li a:hover { background: var(--c-accent); color: #fff; } +.nav-bar li a.active { background: var(--c-accent); color: #fff; } + +@media (max-width: 600px) { + .header-top { + flex-wrap: wrap; + gap: 8px 12px; + padding: 10px 16px; + } + .tagline { display: none; } + .header-search { + order: 3; + flex: 1 0 100%; + max-width: none; + min-width: 0; + margin-left: 0; + } + .header-search form, + .header-search input[type=text], + .header-search input[type=search] { + min-width: 0; + width: 100%; + } + .header-account { margin-left: auto; } + .nav-bar ul { padding: 0 8px; } + .nav-bar li a { + padding: 9px 10px; + font-size: 12px; + } +} + +/* ---- Layout ---- */ + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 28px; +} +@media (max-width: 900px) { + .layout { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 600px) { + .container { padding: 16px; } + .article-card { + grid-template-columns: 100px minmax(0, 1fr); + gap: 12px; + } + .article-card .thumb { + width: 100px; + height: 75px; + } + .article-card .summary { display: none; } + .article-detail h1 { font-size: 25px; } + .source-block dt { + display: block; + width: auto; + } +} + +/* ---- Cards & lists ---- */ + +.section-heading { + display: flex; + align-items: baseline; + gap: 12px; + margin: 28px 0 14px; + padding-bottom: 6px; + border-bottom: 2px solid var(--c-navy); +} +.section-heading h2 { + margin: 0; + font-size: 18px; + color: var(--c-navy); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.section-heading a.see-all { font-size: 13px; } + +.article-card { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid var(--c-border); +} +.article-card .thumb { + width: 160px; + height: 110px; + overflow: hidden; + border-radius: 4px; + background: var(--c-soft); +} +.article-card .thumb img { width: 100%; height: 100%; object-fit: cover; } +.article-card .body { min-width: 0; } +.article-card h3 { + margin: 0 0 6px; + font-size: 17px; + line-height: 1.3; +} +.article-card h3 a { color: var(--c-text); overflow-wrap: anywhere; } +.article-card h3 a:hover { color: var(--c-link); } +.article-card .meta { + font-size: 12px; + color: var(--c-muted); + margin-bottom: 6px; +} +.article-card .meta .tag { + display: inline-block; + background: var(--c-soft); + color: var(--c-navy); + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + margin-right: 6px; +} +.article-card .summary { color: #444; font-size: 14px; } + +.featured-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 16px; + margin: 12px 0 24px; +} +.feat-main { grid-row: span 2; } +@media (max-width: 800px) { + .featured-grid { grid-template-columns: minmax(0, 1fr); } + .feat-main { grid-row: auto; } +} +.feat-main, .feat-side { + background: #fff; + border: 1px solid var(--c-border); + border-radius: 4px; + overflow: hidden; +} +.feat-main .thumb { height: 280px; background: var(--c-soft); } +.feat-side .thumb { height: 130px; background: var(--c-soft); } +.feat-main .thumb img, +.feat-side .thumb img { width: 100%; height: 100%; object-fit: cover; } +.feat-main .pad { padding: 14px 16px 18px; } +.feat-side .pad { padding: 10px 12px 14px; } +.feat-main h2, .feat-side h3 { margin: 4px 0 6px; line-height: 1.25; } +.feat-main h2 { font-size: 22px; } +.feat-main h2 a, .feat-side h3 a { color: var(--c-text); } +.feat-main h2 a:hover, .feat-side h3 a:hover { color: var(--c-link); } + +/* ---- Sidebar ---- */ + +.sidebar { font-size: 14px; } +.sidebar .widget { + background: var(--c-soft); + border: 1px solid var(--c-border); + border-radius: 4px; + padding: 14px 16px; + margin-bottom: 18px; +} +.sidebar .widget h3 { + margin: 0 0 10px; + font-size: 14px; + color: var(--c-navy); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--c-border); + padding-bottom: 6px; +} +.sidebar ol, .sidebar ul { + margin: 0; + padding-left: 18px; +} +.sidebar li { margin-bottom: 8px; line-height: 1.35; } + +/* ---- Article detail ---- */ + +.article-detail { + min-width: 0; + background: #fff; +} +.article-detail .crumbs { + font-size: 13px; + color: var(--c-muted); + margin-bottom: 8px; +} +.article-detail h1 { + margin: 6px 0 8px; + font-size: 30px; + line-height: 1.2; + color: var(--c-text); +} +.article-detail .subtitle { + font-size: 17px; + color: #333; + margin: 0 0 14px; + line-height: 1.4; +} +.article-detail .byline { + font-size: 13px; + color: var(--c-muted); + margin-bottom: 14px; + border-bottom: 1px solid var(--c-border); + padding-bottom: 12px; +} +.article-detail .byline strong { color: #333; } +.article-detail .hero-image { + margin: 0 0 16px; + border-radius: 4px; + overflow: hidden; + background: var(--c-soft); +} +.article-detail .hero-image img { width: 100%; height: auto; } +.article-detail .body p { + margin: 0 0 14px; + font-size: 16px; + line-height: 1.65; +} +.source-block { + margin: 22px 0; + padding: 14px 16px; + background: var(--c-soft); + border-left: 4px solid var(--c-accent); + border-radius: 3px; + font-size: 14px; +} +.source-block dt { + display: inline-block; + font-weight: 700; + width: 130px; + color: var(--c-navy); +} +.source-block dd { display: inline; margin: 0; } +.source-block dl > div { margin-bottom: 6px; } + +.action-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 16px 0; + padding: 10px 0; + border-top: 1px solid var(--c-border); + border-bottom: 1px solid var(--c-border); +} +.btn { + display: inline-block; + padding: 7px 14px; + background: var(--c-accent); + color: #fff; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; + font-size: 14px; + font-weight: 600; +} +.btn:hover { background: var(--c-navy); color: #fff; text-decoration: none; } +.btn.secondary { background: #fff; color: var(--c-navy); border-color: var(--c-navy); } +.btn.secondary:hover { background: var(--c-navy); color: #fff; } +.btn.danger { background: var(--c-warn); } +.save-note-input { + width: min(240px, 100%); + padding: 7px 8px; + border: 1px solid #ccc; + border-radius: 3px; +} + +/* ---- Comments ---- */ + +.comments-section { margin-top: 32px; } +.comments-section h2 { + font-size: 18px; + color: var(--c-navy); + border-bottom: 2px solid var(--c-navy); + padding-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.comment { + border-left: 3px solid var(--c-border); + padding: 8px 0 8px 12px; + margin: 8px 0; +} +.comment .head { + font-size: 13px; + color: var(--c-muted); + margin-bottom: 4px; +} +.comment .head a.author { font-weight: 700; color: var(--c-navy); } +.comment .body { font-size: 15px; line-height: 1.45; } +.comment-form textarea { + width: 100%; + min-height: 100px; + padding: 10px; + border: 1px solid var(--c-border); + border-radius: 4px; + font: inherit; +} + +/* ---- Forms ---- */ + +.form-card { + max-width: 480px; + margin: 30px auto; + padding: 26px 28px; + background: #fff; + border: 1px solid var(--c-border); + border-radius: 4px; + box-shadow: 0 2px 6px rgba(15, 30, 75, 0.04); +} +.form-card h1 { + margin: 0 0 16px; + font-size: 22px; + color: var(--c-navy); +} +.form-card .field { margin-bottom: 14px; } +.form-card label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; + color: #333; +} +.form-card input[type=text], .form-card input[type=email], +.form-card input[type=password], .form-card textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--c-border); + border-radius: 3px; + font: inherit; +} +.form-card .errors { color: var(--c-warn); font-size: 13px; } +.form-card .actions { margin-top: 18px; } +.form-card .alt { font-size: 13px; margin-top: 14px; color: var(--c-muted); } + +.flash { + padding: 10px 14px; + margin: 0 0 14px; + border-radius: 3px; + font-size: 14px; +} +.flash-success { background: #e2f6e8; color: var(--c-success); border: 1px solid #b9e2c4; } +.flash-error { background: #fdecea; color: var(--c-warn); border: 1px solid #f5c2bb; } +.flash-info { background: #e6f1fb; color: var(--c-link); border: 1px solid #c2dbf2; } + +/* ---- Pagination ---- */ + +.pagination { + margin: 22px 0; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.pagination .page, +.pagination .arrow { + display: inline-block; + padding: 5px 11px; + border: 1px solid var(--c-border); + border-radius: 3px; + font-size: 13px; + color: var(--c-link); + background: #fff; +} +.pagination .page.active { + background: var(--c-navy); + color: #fff; + border-color: var(--c-navy); +} +.pagination .arrow.disabled { + color: #aaa; + background: var(--c-soft); + pointer-events: none; +} + +/* ---- Footer ---- */ + +.site-footer { + background: var(--c-navy-dark); + color: #cfd6e6; + font-size: 13px; + padding: 20px; + margin-top: 36px; +} +.site-footer .container { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.site-footer a { color: #cfd6e6; } +.site-footer a:hover { color: #fff; } + +/* ---- Misc ---- */ + +.text-muted { color: var(--c-muted); font-size: 13px; } +.tag-pill { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + background: var(--c-accent); + color: #fff; + border-radius: 3px; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.4px; +} +.profile-head { + background: var(--c-soft); + padding: 18px 20px; + border-radius: 4px; + margin-bottom: 20px; +} +.profile-head h1 { margin: 0 0 4px; color: var(--c-navy); } +.profile-stats { display: flex; flex-wrap: wrap; gap: 18px; font-size: 14px; color: var(--c-muted); } +.member-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } +.member-card { display: flex; flex-direction: column; align-items: flex-start; padding: 18px; border: 1px solid var(--c-border); border-radius: 4px; background: var(--c-card); } +.member-card h3, .member-card p { margin: 0 0 10px; } +.member-card .btn { margin-top: auto; } diff --git a/sites/phys_org/static/icons/.gitkeep b/sites/phys_org/static/icons/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/phys_org/static/icons/favicon.ico b/sites/phys_org/static/icons/favicon.ico new file mode 100644 index 000000000..2beaea967 Binary files /dev/null and b/sites/phys_org/static/icons/favicon.ico differ diff --git a/sites/phys_org/static/icons/placeholder.svg b/sites/phys_org/static/icons/placeholder.svg new file mode 100644 index 000000000..b8bf5b88b --- /dev/null +++ b/sites/phys_org/static/icons/placeholder.svg @@ -0,0 +1,9 @@ + + + + + + + phys.org + diff --git a/sites/phys_org/static/js/.gitkeep b/sites/phys_org/static/js/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/phys_org/tasks.jsonl b/sites/phys_org/tasks.jsonl new file mode 100644 index 000000000..84cc2b779 --- /dev/null +++ b/sites/phys_org/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name":"Phys.org","id":"Phys.org--0","ques":"Find the article 'Magnetic checkerboard separates microparticles by size and sends them along different paths' in the Physics category and report which journal it cites as its source.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must visit the Physics category and the specified article detail page.\n- The final answer must report the source journal displayed on that article.\n- An empty answer or an answer without the required navigation is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--1","ques":"Browse the Physics category's Recent list, open the article titled 'Quantum circuit test finally exposes what has been warping performance', and report the institution listed as 'Provided by'.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must visit the Physics Recent list and open the specified article from that list.\n- The final answer must report the institution shown in the Provided by field.\n- An empty answer or an answer without both list and article evidence is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--2","ques":"Search for 'quantum' on phys.org. Among the matching results, find the article whose title mentions 'a trillionth of a billionth of a joule' and report its source journal.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must perform the quantum search and open the matching article detail page.\n- The final answer must report that article's source journal.\n- An empty answer or a correct-looking answer without search and detail navigation is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--3","ques":"Browse the Trending page on phys.org, open the article currently ranked #3 by views, and report the institution listed as 'Provided by' on its detail page.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must visit Trending and open the article ranked third by views.\n- The final answer must report the institution shown in the article's Provided by field.\n- An empty answer or an answer without the ordered list and article detail is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--4","ques":"Sign in as alice.j@test.com (password TestPass123!), open the user's saved article list, and report how many of those saved articles belong to the 'Astronomy & Space' category.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must show sign-in navigation and the authenticated saved-articles page.\n- The final answer must give the count of saved items in Astronomy & Space.\n- An empty answer or an answer without the saved-list visit is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--5","ques":"Sign in as bob.c@test.com (password TestPass123!), open the saved article whose user note is 'Pitch to editor', and report the publication venue named in the article text.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must sign in as the requested account, visit the saved list, and open the article carrying the specified note.\n- The final answer must report the publication venue named in the article text.\n- An empty answer or an answer without all required navigation is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--6","ques":"Sign in as carol.d@test.com (password TestPass123!) and post a new top-level comment with the text 'Reviewed for our weekly journal club' on any article in the Biology category, then report the article's title.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must sign in, visit Biology, and open the article receiving the comment.\n- The after-state must contain a new top-level comment by the requested user with the exact requested text on a Biology article.\n- The final answer must report that article's title; self-report without the database mutation is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--7","ques":"Sign in as david.k@test.com (password TestPass123!), find the Nanotechnology article 'Engineered exosomes reverse sleep deprivation brain damage in mice', confirm it is not already saved, save it with the note 'Compare with our process', then verify it appears in your saved list and report the subsection shown for the article.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must sign in as the requested account, visit Nanotechnology, open the named initially-unsaved article, save it with the exact note, and return to Saved.\n- The after-state must equal the initial saved state plus that one article and note.\n- The final answer must report the article subsection shown by the mirror; self-report without the exact mutation is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--8","ques":"Open the Members directory, find the public profile for user 'carol_d', and report the count of public comments shown in the profile stats.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must visit the Members directory and open carol_d's public profile from it.\n- The final answer must bind the displayed number to the public comment count.\n- An empty answer or an answer without directory and profile evidence is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--9","ques":"Search the phys.org mirror for 'graphene spin'. Compare the publication dates of 'Machine learning proves that graphene is hydrophobic' and 'Hourglass nanographenes unlock strong, robust multi-spin entanglement'. Report which was published earlier and its source journal.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must perform the requested search and open both named article detail pages.\n- The final answer must identify the earlier article and bind its source journal to that article.\n- An empty answer or an answer without both comparison pages is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--10","ques":"On the phys.org Astronomy & Space category page, sort by 'Popular', locate the article 'How a single star can reshape an entire galaxy', and report both its rank in that list and the institution listed as 'Provided by' on its detail page.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must open Astronomy & Space with Popular sorting, locate the named article in that list, and open its detail page.\n- The final answer must report both the article's Popular-list rank and its Provided by institution.\n- An empty answer or an answer without ordered list and detail evidence is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--11","ques":"Compare the publication dates of the two phys.org articles 'Magnetic checkerboard separates microparticles by size and sends them along different paths' and 'Quantum geometry applied to light-based systems expands toolkit for topological photonics'. Report which one was published earlier.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must open both named article detail pages.\n- The final answer must identify which article has the earlier displayed publication date.\n- An empty answer or an answer without both comparison pages is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--12","ques":"Open Astronomy & Space with the Popular sort, select the article currently ranked #2, and inspect its comment thread where user bob_c replied to a top-level comment by alice_j. Report the full text of bob_c's reply.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must open Astronomy & Space with Popular sorting, select the second-ranked article, and inspect its comment thread.\n- The final answer must reproduce the full reply by bob_c beneath alice_j's top-level comment.\n- An empty, partial, or unsupported answer is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--13","ques":"Register a new account on phys.org with a username, email, full name, and non-empty password of your choice. Then open Account Settings, set the Location field to 'Berlin, Germany', and save. Report the username that appears in the page header after saving.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must submit registration with a new username, email, full name, and non-empty password, then visit and submit Account Settings.\n- The after-state must equal the initial user state plus exactly one account whose entered password matches its hash and whose saved location is Berlin, Germany.\n- The final answer must report that new account's username as shown after saving; an unrelated username or self-report without the exact database state is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--14","ques":"Sign in as alice.j@test.com (password TestPass123!), open the article 'How a single star can reshape an entire galaxy', and remove it from her saved articles. Then visit the saved-articles page and report (a) how many items remain in the saved list and (b) the title of the most-recently-saved article shown at the top of the list.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must sign in, open the named article, remove it, and visit the saved list.\n- The after-state must equal the initial saved list with only that article removed.\n- The final answer must report both the remaining count and the top saved title; self-report without the database change is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--15","ques":"On the phys.org homepage, open the sixth article in the 'Trending now' sidebar. From its detail page, follow its category and switch that category to 'Popular'. Report the article title, its category, and its rank in that category's Popular list.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must start from the homepage, open the sixth Trending now article, follow its category link, and switch that category to Popular.\n- The final answer must bind the selected article title, category, and Popular-list rank.\n- An empty answer or an answer without the ordered click chain is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--16","ques":"On phys.org, search for 'capture materials' and use the category filter to restrict to Chemistry. Among the filtered results, find the article about polyionic liquids and report its source journal.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must search for capture materials with the Chemistry filter and open the matching polyionic-liquids article from those results.\n- The final answer must report the source journal shown on that article.\n- An empty answer or an answer without filtered-search and detail evidence is a FAIL."} +{"web_name":"Phys.org","id":"Phys.org--17","ques":"Sign in as alice.j@test.com (password TestPass123!), open Account Settings without performing a search, and report the second most recent query shown in the recent search history widget.","web":"http://localhost:40017/","upstream_url":"https://phys.org/","verifier_path":"sites/phys_org/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS:\n- The trajectory must sign in as the requested account and visit Account Settings without first changing search history.\n- The final answer must report the second query in the recent-search-history widget.\n- An empty answer, wrong account, changed history, or missing Account Settings evidence is a FAIL."} diff --git a/sites/phys_org/templates/.gitkeep b/sites/phys_org/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/phys_org/templates/_macros.html b/sites/phys_org/templates/_macros.html new file mode 100644 index 000000000..f9f045e0f --- /dev/null +++ b/sites/phys_org/templates/_macros.html @@ -0,0 +1,47 @@ +{% macro article_card(a) -%} + +{%- endmacro %} + +{% macro pager(pagination, endpoint, kw={}) -%} +{% if pagination.pages > 1 %} + +{% endif %} +{%- endmacro %} diff --git a/sites/phys_org/templates/account.html b/sites/phys_org/templates/account.html new file mode 100644 index 000000000..eb423602b --- /dev/null +++ b/sites/phys_org/templates/account.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% block title %}Account settings — Phys.org Mirror{% endblock %} +{% block content %} +
+
+

Account settings

+
+ {{ form.csrf_token }} +
+ + +
+
+ + +
+
+ + {{ form.full_name(size=40) }} +
+
+ + {{ form.location(size=40) }} +
+
+ + {{ form.bio(rows=4, cols=50) }} +
+
+ + {{ form.interests(size=50) }} +
+
+
+
+ +
+{% endblock %} diff --git a/sites/phys_org/templates/article_detail.html b/sites/phys_org/templates/article_detail.html new file mode 100644 index 000000000..7733857d9 --- /dev/null +++ b/sites/phys_org/templates/article_detail.html @@ -0,0 +1,148 @@ +{% extends 'base.html' %} +{% from '_macros.html' import article_card %} +{% block title %}{{ article.title }} — Phys.org Mirror{% endblock %} +{% block content %} +
+
+
+ Home + {% if article.category %} + / {{ article.category.name }} + {% endif %} + {% if article.subsection %} / {{ article.subsection }}{% endif %} +
+ +

{{ article.title }}

+ {% if article.subtitle %}

{{ article.subtitle }}

{% endif %} + + + + {% if article.image_filename %} +
+ +
+ {% endif %} + +
+ {% for p in article.get_paragraphs() %} +

{{ p|sanitize }}

+ {% endfor %} +
+ + {% if article.source_journal or article.source_institution or article.doi_url %} +
+
+ {% if article.source_journal %} +
Journal
{{ article.source_journal }}
+ {% endif %} + {% if article.source_institution %} +
Provided by
{{ article.source_institution }}
+ {% endif %} + {% if article.doi_url %} + + {% endif %} +
+
+ {% endif %} + +
+ {% if current_user.is_authenticated %} +
+ {{ save_form.csrf_token }} + + {% if is_saved %} + + {% else %} + + + {% endif %} +
+ {% else %} + Sign in to save + {% endif %} + {{ article.comment_count }} comment{{ '' if article.comment_count == 1 else 's' }} +
+ +
+

Comments

+ {% if comment_tree %} + {% for entry in comment_tree %} + {% set c = entry.comment %} +
+
+ {{ c.user.username }} + · {{ c.time_ago }} + {% if current_user.is_authenticated %} + · Reply + {% endif %} +
+
{{ c.text }}
+
+ {% endfor %} + {% else %} +

No comments yet.

+ {% endif %} + + {% if current_user.is_authenticated %} +
+ {{ form.csrf_token }} + + + +
+
+ + {% else %} +

Sign in to comment.

+ {% endif %} +
+
+ + +
+{% endblock %} diff --git a/sites/phys_org/templates/base.html b/sites/phys_org/templates/base.html new file mode 100644 index 000000000..b7de40657 --- /dev/null +++ b/sites/phys_org/templates/base.html @@ -0,0 +1,73 @@ + + + + + +{% block title %}{{ site_name }}{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + + diff --git a/sites/phys_org/templates/category.html b/sites/phys_org/templates/category.html new file mode 100644 index 000000000..e95463f56 --- /dev/null +++ b/sites/phys_org/templates/category.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} +{% from '_macros.html' import article_card, pager %} +{% block title %}{{ category.name }} — Phys.org Mirror{% endblock %} +{% block content %} +
+
+
+

{{ category.name }}

+ {{ pagination.total }} article{{ '' if pagination.total == 1 else 's' }} + + Recent · + Popular + +
+ {% if category.description %}

{{ category.description }}

{% endif %} + {% for a in pagination.items %}{{ article_card(a) }}{% endfor %} + {% if not pagination.items %}

No articles in this category yet.

{% endif %} + {{ pager(pagination, 'category', {'slug': category.slug, 'sort': sort}) }} +
+ +
+{% endblock %} diff --git a/sites/phys_org/templates/index.html b/sites/phys_org/templates/index.html new file mode 100644 index 000000000..896c308dd --- /dev/null +++ b/sites/phys_org/templates/index.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} +{% from '_macros.html' import article_card %} +{% block title %}Phys.org Mirror — Science, Technology, Research news{% endblock %} + +{% block content %} + +{% if featured %} + +{% endif %} + +
+
+
+

Latest News

+ All latest → +
+ {% for a in latest %}{{ article_card(a) }}{% endfor %} + + {% for cat, items in by_cat %} +
+

{{ cat.name }}

+ More in {{ cat.name }} → +
+ {% for a in items %}{{ article_card(a) }}{% endfor %} + {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/phys_org/templates/login.html b/sites/phys_org/templates/login.html new file mode 100644 index 000000000..1f5338983 --- /dev/null +++ b/sites/phys_org/templates/login.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}Sign in — Phys.org Mirror{% endblock %} +{% block content %} +
+

Sign in

+
+ {{ form.csrf_token }} +
+ + {{ form.email(size=40) }} + {% if form.email.errors %}
{{ form.email.errors[0] }}
{% endif %} +
+
+ + {{ form.password(size=40) }} + {% if form.password.errors %}
{{ form.password.errors[0] }}
{% endif %} +
+
+ +
+
No account? Create one.
+
+
+{% endblock %} diff --git a/sites/phys_org/templates/register.html b/sites/phys_org/templates/register.html new file mode 100644 index 000000000..858e68204 --- /dev/null +++ b/sites/phys_org/templates/register.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% block title %}Create account — Phys.org Mirror{% endblock %} +{% block content %} +
+

Create account

+
+ {{ form.csrf_token }} +
+ + {{ form.username(size=40) }} + {% if form.username.errors %}
{{ form.username.errors[0] }}
{% endif %} +
+
+ + {{ form.email(size=40) }} + {% if form.email.errors %}
{{ form.email.errors[0] }}
{% endif %} +
+
+ + {{ form.full_name(size=40) }} +
+
+ + {{ form.password(size=40) }} + {% if form.password.errors %}
{{ form.password.errors[0] }}
{% endif %} +
+
+ +
+
Already have an account? Sign in.
+
+
+{% endblock %} diff --git a/sites/phys_org/templates/saved.html b/sites/phys_org/templates/saved.html new file mode 100644 index 000000000..66851c6de --- /dev/null +++ b/sites/phys_org/templates/saved.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% block title %}Saved articles — Phys.org Mirror{% endblock %} +{% block content %} +
+

Your saved articles

+
+{% if items %} + {% for it in items %} + {% set a = it.article %} + + {% endfor %} +{% else %} +

You haven't saved any articles yet. Browse the homepage and click "Save article" on any story.

+{% endif %} +{% endblock %} diff --git a/sites/phys_org/templates/search.html b/sites/phys_org/templates/search.html new file mode 100644 index 000000000..16cba6eff --- /dev/null +++ b/sites/phys_org/templates/search.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% from '_macros.html' import article_card %} +{% block title %}Search — Phys.org Mirror{% endblock %} +{% block content %} +
+

Search

+ {% if query %}{{ total }} result{{ '' if total == 1 else 's' }} for "{{ query }}"{% endif %} +
+ +
+ + + +
+ +{% if query %} + {% if results %} + {% for a in results %}{{ article_card(a) }}{% endfor %} + + {% else %} +

No results matched your search. Try fewer keywords or a different category.

+ {% endif %} +{% else %} +

Type a query above to search across {{ all_categories|length }} categories.

+{% endif %} +{% endblock %} diff --git a/sites/phys_org/templates/trending.html b/sites/phys_org/templates/trending.html new file mode 100644 index 000000000..2011573a1 --- /dev/null +++ b/sites/phys_org/templates/trending.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% from '_macros.html' import article_card, pager %} +{% block title %}{{ heading }} — Phys.org Mirror{% endblock %} +{% block content %} +
+

{{ heading }}

+ {{ description }} +
+{% for a in pagination.items %}{{ article_card(a) }}{% endfor %} +{{ pager(pagination, pager_endpoint) }} +{% endblock %} diff --git a/sites/phys_org/templates/user.html b/sites/phys_org/templates/user.html new file mode 100644 index 000000000..b9cfd9d19 --- /dev/null +++ b/sites/phys_org/templates/user.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} +{% block title %}{{ user.username }} — Phys.org Mirror{% endblock %} +{% block content %} +
+

{{ user.full_name or user.username }}

+
@{{ user.username }}{% if user.location %} · {{ user.location }}{% endif %} · joined {{ user.created_at.strftime('%b %Y') if user.created_at else '' }}
+ {% if user.bio %}

{{ user.bio }}

{% endif %} +
+ {{ saved_count }} saved + {{ comment_count }} comments + {% if user.interests %}Interests: {{ user.interests }}{% endif %} +
+
+ +

Recent comments

+{% if recent_comments %} + {% for c in recent_comments %} +
+
+ on {{ c.article.title }} + · {{ c.time_ago }} +
+
{{ c.text }}
+
+ {% endfor %} +{% else %} +

No comments yet.

+{% endif %} +{% endblock %} diff --git a/sites/phys_org/templates/users.html b/sites/phys_org/templates/users.html new file mode 100644 index 000000000..5f43cafc7 --- /dev/null +++ b/sites/phys_org/templates/users.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% block title %}Members — Phys.org Mirror{% endblock %} +{% block content %} +
+

Members

+ Browse public member profiles. +
+
+ {% for user in users %} +
+
+

{{ user.full_name or user.username }}

+

@{{ user.username }}{% if user.location %} · {{ user.location }}{% endif %}

+
+ {% if user.bio %}

{{ user.bio }}

{% endif %} + {% if user.interests %}

Interests: {{ user.interests }}

{% endif %} + View profile +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/phys_org/verify/test_environment_quality.py b/sites/phys_org/verify/test_environment_quality.py new file mode 100644 index 000000000..ccae2abd4 --- /dev/null +++ b/sites/phys_org/verify/test_environment_quality.py @@ -0,0 +1,204 @@ +"""Regression checks for Phys.org data quality, task facts, and local assets.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import sqlite3 +import subprocess +import tarfile +import tempfile +import unittest +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path, PurePosixPath + +SITE_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = SITE_DIR.parents[1] +SEED_DB = SITE_DIR / "instance_seed" / "phys_org.db" + + +def _load_seed_data(): + spec = importlib.util.spec_from_file_location("phys_org_seed_data", SITE_DIR / "seed_data.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +@contextmanager +def _connection(): + connection = sqlite3.connect(f"file:{SEED_DB}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + try: + yield connection + finally: + connection.close() + + +def _search_rank(connection: sqlite3.Connection, query: str, target_slug: str, + category: str | None = None) -> tuple[int | None, int]: + stop_words = { + "the", "a", "an", "in", "on", "at", "to", "for", "of", "and", "or", + "is", "it", "by", "with", "as", "be", "this", "that", "are", "was", + "were", "from", "how", "what", "why", "we", "i", + } + tokens = [token.casefold() for token in re.split(r"\W+", query) if token and token.casefold() not in stop_words and len(token) > 1] + sql = "SELECT a.*,c.slug AS category_slug FROM articles a JOIN categories c ON c.id=a.category_id" + params: tuple[str, ...] = () + if category: + sql += " WHERE c.slug=?" + params = (category,) + scored = [] + for row in connection.execute(sql, params): + blob = f"{row['title']}\n{row['subtitle']}\n{row['body']}".casefold() + score = sum(token in blob for token in tokens) + if score: + scored.append((row, score)) + scored.sort(key=lambda pair: (-pair[1], -datetime.fromisoformat(pair[0]["published_at"]).timestamp())) + rank = next((index for index, (row, _) in enumerate(scored, 1) if row["slug"] == target_slug), None) + return rank, len(scored) + + +class EnvironmentQualityTests(unittest.TestCase): + def test_agent_pre_pr_sweep_covers_every_registered_site(self) -> None: + agent_guide = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8") + startup = (REPO_ROOT / "websyn_start.sh").read_text(encoding="utf-8") + site_match = re.search(r"SITES=\((.*?)\)", startup, re.DOTALL) + sweep_match = re.search(r"for p in \$\(seq (\d+) (\d+)\); do", agent_guide) + self.assertIsNotNone(site_match) + self.assertIsNotNone(sweep_match) + sites = site_match.group(1).split() + sweep_start, sweep_end = map(int, sweep_match.groups()) + self.assertEqual(18, len(sites)) + self.assertEqual((41000, 41000 + len(sites) - 1), (sweep_start, sweep_end)) + + def test_task_ids_urls_and_verifier_paths_are_consistent(self) -> None: + rows = [json.loads(line) for line in (SITE_DIR / "tasks.jsonl").read_text(encoding="utf-8").splitlines()] + self.assertEqual(18, len(rows)) + for index, row in enumerate(rows): + with self.subTest(task=index): + self.assertEqual(f"Phys.org--{index}", row["id"]) + self.assertEqual("http://localhost:40017/", row["web"]) + self.assertEqual(f"sites/phys_org/verify/verify_{index}.py", row["verifier_path"]) + + def test_only_source_verified_metadata_is_present(self) -> None: + seed_data = _load_seed_data() + with _connection() as connection: + rows = connection.execute( + "SELECT slug,title,author_name,source_journal,source_institution,doi_url FROM articles" + ).fetchall() + self.assertEqual(210, len(rows)) + for row in rows: + expected = seed_data.SOURCE_METADATA_OVERRIDES.get(row["slug"]) + with self.subTest(slug=row["slug"]): + if expected: + self.assertEqual(expected["title"], row["title"]) + self.assertRegex(expected["upstream_url"], r"^https://(?:phys\.org|techxplore\.com)/") + self.assertTrue(expected["evidence"]) + self.assertEqual(expected["journal"], row["source_journal"]) + self.assertEqual(expected["institution"], row["source_institution"]) + self.assertEqual(expected["doi"], row["doi_url"]) + self.assertEqual(expected["author"], row["author_name"]) + else: + self.assertEqual("", row["source_journal"]) + self.assertEqual("", row["source_institution"]) + self.assertEqual("", row["doi_url"]) + self.assertTrue(row["author_name"]) + self.assertNotIn(row["author_name"], { + "Nina Kowalski", "Elena Yamamoto", "Sarah Patel", "Michael Garcia", + "Ananya Nguyen", "Jorge Rossi", "Mei Tanaka", "David Andersen", + }) + + def test_bodies_and_summaries_do_not_contain_generated_filler(self) -> None: + filler = ( + "The findings, the team writes", + "Beyond the immediate result", + "Independent researchers not involved", + ) + with _connection() as connection: + rows = connection.execute("SELECT subtitle,body,doi_url FROM articles").fetchall() + for row in rows: + for phrase in filler: + self.assertNotIn(phrase, row["body"]) + self.assertNotRegex(row["doi_url"], r"/phys\.2026\.\d+$") + self.assertLessEqual(len(row["subtitle"]), 240) + if row["subtitle"] != row["body"]: + self.assertTrue(row["subtitle"].endswith("…")) + + def test_ground_truth_metadata_and_rankings(self) -> None: + expected = { + "magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-": ("Physical Review Letters", "University of Tübingen"), + "quantum-circuit-test-finally-exposes-what-has-been-warping-performance": ("Nature Physics", "Massachusetts Institute of Technology"), + "method-for-measuring-energy-amounts-less-than-a-trillionth-of-a-billio": ("Nature Electronics", "Aalto University"), + "hourglass-nanographenes-unlock-strong-robust-multi-spin-entanglement": ("Nature Synthesis", "National University of Singapore"), + "how-a-single-star-can-reshape-an-entire-galaxy": ("Astronomy & Astrophysics", "Leiden University"), + "anion-swap-unlocks-sevenfold-co-capture-in-polyionic-liquids": ("Reaction Chemistry & Engineering", ""), + } + with _connection() as connection: + for slug, values in expected.items(): + row = connection.execute( + "SELECT source_journal,source_institution FROM articles WHERE slug=?", (slug,) + ).fetchone() + self.assertEqual(values, tuple(row)) + hypersonic = connection.execute( + "SELECT body,source_journal,source_institution FROM articles WHERE slug=?", + ("cracking-the-code-of-hypersonic-flight-a-decade-of-experiments-maps-tu",), + ).fetchone() + self.assertIn("AIAA SCITECH 2026 Forum", hypersonic["body"]) + self.assertEqual(("", ""), (hypersonic["source_journal"], hypersonic["source_institution"])) + astronomy = connection.execute( + "SELECT a.slug FROM articles a JOIN categories c ON c.id=a.category_id WHERE c.slug='astronomy' ORDER BY a.views DESC,a.published_at DESC" + ).fetchall() + physics = connection.execute( + "SELECT a.slug FROM articles a JOIN categories c ON c.id=a.category_id WHERE c.slug='physics' ORDER BY a.views DESC,a.published_at DESC" + ).fetchall() + trending = connection.execute("SELECT slug FROM articles ORDER BY views DESC,published_at DESC").fetchall() + self.assertEqual("how-a-single-star-can-reshape-an-entire-galaxy", astronomy[2][0]) + self.assertEqual("jwst-spots-two-early-black-holes-growing-far-faster-than-their-galaxie", astronomy[1][0]) + self.assertEqual("good-vibrations-for-quantum-communications-engineers-couple-single-pho", physics[1][0]) + self.assertEqual("magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-", trending[2][0]) + self.assertEqual("good-vibrations-for-quantum-communications-engineers-couple-single-pho", trending[5][0]) + + def test_task_searches_have_distractors_and_nonfirst_targets(self) -> None: + with _connection() as connection: + cases = [ + ("quantum", "method-for-measuring-energy-amounts-less-than-a-trillionth-of-a-billio", None, 5), + ("graphene spin", "hourglass-nanographenes-unlock-strong-robust-multi-spin-entanglement", None, 2), + ("graphene spin", "machine-learning-proves-that-graphene-is-hydrophobic", None, 3), + ("capture materials", "anion-swap-unlocks-sevenfold-co-capture-in-polyionic-liquids", "chemistry", 6), + ] + for query, slug, category, expected_rank in cases: + with self.subTest(query=query, slug=slug): + rank, total = _search_rank(connection, query, slug, category) + self.assertEqual(expected_rank, rank) + self.assertGreaterEqual(total, 6) + + def test_no_empty_categories_are_seeded(self) -> None: + with _connection() as connection: + rows = connection.execute( + "SELECT c.slug,count(a.id) FROM categories c LEFT JOIN articles a ON a.category_id=c.id GROUP BY c.id" + ).fetchall() + self.assertEqual(7, len(rows)) + self.assertEqual([], [row for row in rows if row[1] == 0]) + + def test_asset_packer_excludes_appledouble_entries(self) -> None: + with tempfile.TemporaryDirectory(prefix="phys-org-assets-") as temp_dir: + subprocess.run( + [str(REPO_ROOT / "scripts" / "extract_assets.sh"), temp_dir, "phys_org"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + archive = Path(temp_dir) / "phys_org.tar.gz" + with tarfile.open(archive, "r:gz") as tar: + names = tar.getnames() + appledouble = [name for name in names if PurePosixPath(name).name.startswith("._")] + self.assertIn("phys_org/instance_seed/phys_org.db", names) + self.assertEqual([], appledouble) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/phys_org/verify/test_verifiers.py b/sites/phys_org/verify/test_verifiers.py new file mode 100644 index 000000000..18d258c5c --- /dev/null +++ b/sites/phys_org/verify/test_verifiers.py @@ -0,0 +1,388 @@ +"""Positive and adversarial tests for every Phys.org verifier.""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from urllib.parse import quote_plus + +import bcrypt + +VERIFY_DIR = Path(__file__).resolve().parent +SEED_DB = VERIFY_DIR.parent / "instance_seed" / "phys_org.db" +BASE_URL = "http://localhost:40017" +PASSWORD = "TestPass123!" + +SLUGS = { + "magnetic": "magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-", + "quantum_circuit": "quantum-circuit-test-finally-exposes-what-has-been-warping-performance", + "tiny_energy": "method-for-measuring-energy-amounts-less-than-a-trillionth-of-a-billio", + "hypersonic": "cracking-the-code-of-hypersonic-flight-a-decade-of-experiments-maps-tu", + "exosomes": "engineered-exosomes-reverse-sleep-deprivation-brain-damage-in-mice", + "hydrophobic": "machine-learning-proves-that-graphene-is-hydrophobic", + "hourglass": "hourglass-nanographenes-unlock-strong-robust-multi-spin-entanglement", + "star": "how-a-single-star-can-reshape-an-entire-galaxy", + "geometry": "quantum-geometry-applied-to-light-based-systems-expands-toolkit-for-to", + "jwst": "jwst-spots-two-early-black-holes-growing-far-faster-than-their-galaxie", + "vibrations": "good-vibrations-for-quantum-communications-engineers-couple-single-pho", + "polyionic": "anion-swap-unlocks-sevenfold-co-capture-in-polyionic-liquids", +} +TITLES = { + "magnetic": "Magnetic checkerboard separates microparticles by size and sends them along different paths", + "exosomes": "Engineered exosomes reverse sleep deprivation brain damage in mice", + "hydrophobic": "Machine learning proves that graphene is hydrophobic", + "hourglass": "Hourglass nanographenes unlock strong, robust multi-spin entanglement", + "vibrations": "Good vibrations for quantum communications: Engineers couple single phonon to single atomic spin", + "top_saved": "More Star Wars-like worlds emerge as 27 planet candidates with two suns discovered", +} +REPLY = "Totally agree on the priors point — the new constraint is much tighter though." +COMMENT = "Reviewed for our weekly journal club" +NOTE = "Compare with our process" + + +def url(path: str) -> str: + return BASE_URL + path + + +def navigate(path: str) -> dict: + return {"url": url(path), "action": "navigate", "params": {}} + + +def fill(path: str, selector: str, text: str) -> dict: + return {"url": url(path), "action": "fill", "params": {"css": selector, "text": text}} + + +def click(path: str, destination: str) -> dict: + return {"url": url(path), "action": "click", "params": {}, "url_after": url(destination)} + + +def login_steps(email: str) -> list[dict]: + return [ + navigate("/login"), + fill("/login", "input[name=email]", email), + fill("/login", "input[name=password]", PASSWORD), + click("/login", "/"), + navigate("/"), + ] + + +class VerifierTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + connection = sqlite3.connect(SEED_DB) + try: + row = connection.execute( + "SELECT a.slug,a.title FROM articles a JOIN categories c ON c.id=a.category_id WHERE c.slug='biology' ORDER BY a.published_at DESC LIMIT 1" + ).fetchone() + finally: + connection.close() + cls.biology_slug, cls.biology_title = row + + def run_verifier(self, task: int, steps: list[dict], answer: str, + mutate=None, task_id: str | None = None) -> tuple[int, dict]: + with tempfile.TemporaryDirectory(prefix=f"phys-verifier-{task}-") as temp_dir: + temp = Path(temp_dir) + initial = temp / "initial.db" + after = temp / "after.db" + shutil.copy2(SEED_DB, initial) + shutil.copy2(SEED_DB, after) + if mutate is not None: + connection = sqlite3.connect(after) + try: + mutate(connection) + connection.commit() + finally: + connection.close() + run_dir = temp / "run" + run_dir.mkdir() + trajectory = { + "task_id": task_id if task_id is not None else f"Phys.org--{task}", + "start_url": url("/"), + "steps": steps, + "final_answer": answer, + } + (run_dir / "trajectory.json").write_text(json.dumps(trajectory), encoding="utf-8") + result = subprocess.run( + [sys.executable, str(VERIFY_DIR / f"verify_{task}.py"), + "--run_dir", str(run_dir), "--initial_db", str(initial), + "--after_db", str(after), "--no_llm", "true"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + try: + verdict = json.loads(result.stdout) + except json.JSONDecodeError as error: + self.fail(f"Verifier {task} produced invalid JSON. stdout={result.stdout!r} stderr={result.stderr!r}: {error}") + return result.returncode, verdict + + def mutate_comment(self, connection: sqlite3.Connection) -> None: + user_id = connection.execute("SELECT id FROM users WHERE username='carol_d'").fetchone()[0] + article_id = connection.execute("SELECT id FROM articles WHERE slug=?", (self.biology_slug,)).fetchone()[0] + connection.execute( + "INSERT INTO comments(text,user_id,article_id,parent_id,score,created_at) VALUES(?,?,?,?,?,?)", + (COMMENT, user_id, article_id, None, 0, "2026-05-14 12:00:00"), + ) + + @staticmethod + def mutate_save(connection: sqlite3.Connection) -> None: + user_id = connection.execute("SELECT id FROM users WHERE username='david_k'").fetchone()[0] + article_id = connection.execute("SELECT id FROM articles WHERE slug=?", (SLUGS["exosomes"],)).fetchone()[0] + connection.execute( + "INSERT INTO saved_articles(user_id,article_id,note,created_at) VALUES(?,?,?,?)", + (user_id, article_id, NOTE, "2026-05-14 12:00:00"), + ) + + @staticmethod + def mutate_register(connection: sqlite3.Connection) -> None: + password_hash = bcrypt.hashpw(b"VerifierPass123!", bcrypt.gensalt()).decode() + connection.execute( + "INSERT INTO users(username,email,password_hash,full_name,bio,location,interests,created_at) VALUES(?,?,?,?,?,?,?,?)", + ("qa_explorer", "qa_explorer@example.com", password_hash, "QA Explorer", "", "Berlin, Germany", "", "2026-05-14 12:00:00"), + ) + + @staticmethod + def mutate_remove(connection: sqlite3.Connection) -> None: + connection.execute( + "DELETE FROM saved_articles WHERE user_id=(SELECT id FROM users WHERE username='alice_j') AND article_id=(SELECT id FROM articles WHERE slug=?)", + (SLUGS["star"],), + ) + + def positive_case(self, task: int): + def article(key): + return f"/article/{SLUGS[key]}" + + def transition(source, destination): + return [click(source, destination), navigate(destination)] + + graphene_search = f"/search?q={quote_plus('graphene spin')}" + capture_search = f"/search?q={quote_plus('capture materials')}&category=chemistry" + cases = { + 0: ([navigate("/category/physics")] + transition("/category/physics", article("magnetic")), "Physical Review Letters", None), + 1: ([navigate("/category/physics")] + transition("/category/physics", article("quantum_circuit")), "Massachusetts Institute of Technology", None), + 2: ([navigate("/search?q=quantum")] + transition("/search?q=quantum", article("tiny_energy")), "Nature Electronics", None), + 3: ([navigate("/trending")] + transition("/trending", article("magnetic")), "University of Tübingen", None), + 4: (login_steps("alice.j@test.com") + transition("/", "/saved"), "There are 4 Astronomy & Space saved articles.", None), + 5: (login_steps("bob.c@test.com") + transition("/", "/saved") + transition("/saved", article("hypersonic")), "The publication venue is the AIAA SCITECH 2026 Forum.", None), + 6: (login_steps("carol.d@test.com") + [navigate("/category/biology")] + transition("/category/biology", f"/article/{self.biology_slug}") + [ + fill(f"/article/{self.biology_slug}", "textarea[name=text]", COMMENT), + click(f"/article/{self.biology_slug}", f"/article/{self.biology_slug}"), + navigate(f"/article/{self.biology_slug}"), + ], self.biology_title, self.mutate_comment), + 7: (login_steps("david.k@test.com") + [navigate("/category/nanotechnology")] + transition("/category/nanotechnology", article("exosomes")) + [ + fill(article("exosomes"), "input[name=note]", NOTE), + click(article("exosomes"), article("exosomes")), + navigate(article("exosomes")), + navigate("/saved"), + ], "The article subsection is Bio & Medicine.", self.mutate_save), + 8: ([navigate("/users")] + transition("/users", "/user/carol_d"), "Carol has 3 public comments.", None), + 9: ([navigate(graphene_search)] + transition(graphene_search, article("hydrophobic")) + [navigate(graphene_search)] + transition(graphene_search, article("hourglass")), f"{TITLES['hourglass']} was published earlier; its journal is Nature Synthesis.", None), + 10: ([navigate("/category/astronomy?sort=popular")] + transition("/category/astronomy?sort=popular", article("star")), "The article is rank #3; Provided by Leiden University.", None), + 11: ([navigate(article("magnetic")), navigate(article("geometry"))], "Quantum geometry applied to light-based systems expands toolkit for topological photonics was published earlier than " + TITLES["magnetic"] + ".", None), + 12: ([navigate("/category/astronomy?sort=popular")] + transition("/category/astronomy?sort=popular", article("jwst")), REPLY, None), + 13: ([ + navigate("/register"), + fill("/register", "input[name=username]", "qa_explorer"), + fill("/register", "input[name=email]", "qa_explorer@example.com"), + fill("/register", "input[name=full_name]", "QA Explorer"), + fill("/register", "input[name=password]", "VerifierPass123!"), + click("/register", "/"), + navigate("/"), + navigate("/account"), + fill("/account", "input[name=location]", "Berlin, Germany"), + click("/account", "/account"), + navigate("/account"), + ], "The username in the header is qa_explorer.", self.mutate_register), + 14: (login_steps("alice.j@test.com") + transition("/", "/saved") + transition("/saved", article("star")) + [ + click(article("star"), article("star")), + navigate(article("star")), + navigate("/saved"), + ], f"5 saved articles remain; the most recent item at the top is {TITLES['top_saved']}.", self.mutate_remove), + 15: ([ + click("/", article("vibrations")), + navigate(article("vibrations")), + click(article("vibrations"), "/category/physics"), + navigate("/category/physics"), + click("/category/physics", "/category/physics?sort=popular"), + navigate("/category/physics?sort=popular"), + ], f"{TITLES['vibrations']} is in Physics and is rank #2 in Popular.", None), + 16: ([navigate(capture_search)] + transition(capture_search, article("polyionic")), "Reaction Chemistry & Engineering", None), + 17: (login_steps("alice.j@test.com") + transition("/", "/account"), "The second query is dark matter halo.", None), + } + return cases[task] + + def test_all_positive_cases_pass(self) -> None: + for task in range(18): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + returncode, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, returncode, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_wrong_task_id_fails_every_verifier(self) -> None: + for task in range(18): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + returncode, verdict = self.run_verifier(task, steps, answer, mutate, task_id="Phys.org--999") + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + self.assertEqual("task_id_matches", verdict["reason"]) + + def test_empty_answer_fails_every_verifier(self) -> None: + for task in range(18): + with self.subTest(task=task): + steps, _, mutate = self.positive_case(task) + returncode, verdict = self.run_verifier(task, steps, "", mutate) + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + self.assertEqual("final_answer_nonempty", verdict["reason"]) + + def test_answer_only_trajectory_fails_every_verifier(self) -> None: + for task in range(18): + with self.subTest(task=task): + _, answer, mutate = self.positive_case(task) + returncode, verdict = self.run_verifier(task, [], answer, mutate) + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + + def test_external_origin_spoof_fails(self) -> None: + spoofed = [ + {"url": "https://evil.example/category/physics", "action": "navigate", "params": {}}, + {"url": f"https://evil.example/article/{SLUGS['magnetic']}", "action": "navigate", "params": {}}, + ] + returncode, verdict = self.run_verifier(0, spoofed, "Physical Review Letters") + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + + def test_reversed_order_fails(self) -> None: + steps = [navigate(f"/article/{SLUGS['tiny_energy']}"), navigate("/search?q=quantum")] + returncode, verdict = self.run_verifier(2, steps, "Nature Electronics") + self.assertNotEqual(0, returncode) + self.assertEqual("ordered_search_to_article", verdict["reason"]) + + def test_ordered_url_visits_without_a_result_click_fail(self) -> None: + steps = [navigate("/category/physics"), navigate(f"/article/{SLUGS['magnetic']}")] + returncode, verdict = self.run_verifier(0, steps, "Physical Review Letters") + self.assertNotEqual(0, returncode) + self.assertEqual("clicked_target_from_physics", verdict["reason"]) + + def test_unsubmitted_login_fails(self) -> None: + steps = [navigate("/login"), fill("/login", "input[name=email]", "alice.j@test.com"), fill("/login", "input[name=password]", PASSWORD), navigate("/saved")] + returncode, verdict = self.run_verifier(4, steps, "There are 4 Astronomy & Space saved articles.") + self.assertNotEqual(0, returncode) + self.assertEqual("login_as_alice", verdict["reason"]) + + def test_overwritten_login_email_fails(self) -> None: + steps = login_steps("alice.j@test.com") + steps.insert(3, fill("/login", "input[name=email]", "bob.c@test.com")) + steps.append(navigate("/saved")) + returncode, verdict = self.run_verifier(4, steps, "There are 4 Astronomy & Space saved articles.") + self.assertNotEqual(0, returncode) + self.assertEqual("login_as_alice", verdict["reason"]) + + def test_numbers_must_be_bound_to_requested_labels(self) -> None: + steps4, _, _ = self.positive_case(4) + returncode4, _ = self.run_verifier(4, steps4, "Physics has 4 saved articles; Astronomy & Space has 3.") + self.assertNotEqual(0, returncode4) + steps8, _, _ = self.positive_case(8) + returncode8, _ = self.run_verifier(8, steps8, "Carol has 3 saved articles and 2 comments.") + self.assertNotEqual(0, returncode8) + + def test_written_counts_and_ordinal_ranks_pass(self) -> None: + steps4, _, _ = self.positive_case(4) + returncode4, verdict4 = self.run_verifier(4, steps4, "There are four Astronomy & Space saved articles.") + self.assertEqual(0, returncode4, verdict4) + steps8, _, _ = self.positive_case(8) + returncode8, verdict8 = self.run_verifier(8, steps8, "Carol has three public comments.") + self.assertEqual(0, returncode8, verdict8) + steps10, _, _ = self.positive_case(10) + returncode10, verdict10 = self.run_verifier(10, steps10, "The article ranks third and is provided by Leiden University.") + self.assertEqual(0, returncode10, verdict10) + steps15, _, _ = self.positive_case(15) + answer15 = f"{TITLES['vibrations']} is in Physics and ranks second in Popular." + returncode15, verdict15 = self.run_verifier(15, steps15, answer15) + self.assertEqual(0, returncode15, verdict15) + + def test_comparison_journal_must_be_bound_to_winner(self) -> None: + steps, _, _ = self.positive_case(9) + bad = f"{TITLES['hourglass']} was earlier; its journal is Advanced Materials. Nature Synthesis belongs to the other article." + returncode, verdict = self.run_verifier(9, steps, bad) + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + + def test_full_reply_accepts_dash_equivalence_and_rejects_partial(self) -> None: + steps, _, _ = self.positive_case(12) + returncode, verdict = self.run_verifier(12, steps, f'bob_c replied, "{REPLY.replace(" — ", " - ")}"') + self.assertEqual(0, returncode, verdict) + returncode, verdict = self.run_verifier(12, steps, "the new constraint is much tighter though") + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + + def test_state_verifiers_reject_unrelated_extra_changes(self) -> None: + cases = (6, 7, 13, 14, 17) + for task in cases: + with self.subTest(task=task): + steps, answer, base_mutate = self.positive_case(task) + def mutate(connection, base_mutate=base_mutate, task=task): + if base_mutate: + base_mutate(connection) + connection.execute("UPDATE users SET bio='unrelated change' WHERE username='bob_c'") + if task == 6: + connection.execute("UPDATE comments SET score=score+1 WHERE id=(SELECT min(id) FROM comments)") + elif task in {7, 14}: + connection.execute("UPDATE saved_articles SET note='unrelated change' WHERE id=(SELECT min(id) FROM saved_articles)") + elif task == 13: + connection.execute("UPDATE users SET bio='unrelated change' WHERE username='alice_j'") + elif task == 17: + connection.execute("UPDATE search_history SET query='unrelated change' WHERE id=(SELECT min(id) FROM search_history)") + returncode, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + + def test_task_13_accepts_an_arbitrary_new_identity(self) -> None: + steps, _, _ = self.positive_case(13) + replacements = { + "qa_explorer": "science_rover", + "qa_explorer@example.com": "science_rover@example.com", + "QA Explorer": "Science Rover", + } + for step in steps: + text = (step.get("params") or {}).get("text") + if text in replacements: + step["params"]["text"] = replacements[text] + + def mutate(connection): + password_hash = bcrypt.hashpw(b"VerifierPass123!", bcrypt.gensalt()).decode() + connection.execute( + "INSERT INTO users(username,email,password_hash,full_name,bio,location,interests,created_at) VALUES(?,?,?,?,?,?,?,?)", + ("science_rover", "science_rover@example.com", password_hash, "Science Rover", "", "Berlin, Germany", "", "2026-05-14 12:00:00"), + ) + + returncode, verdict = self.run_verifier( + 13, steps, "The username in the header is science_rover.", mutate + ) + self.assertEqual(0, returncode, verdict) + + def test_task_7_rejects_a_different_new_nanotechnology_save(self) -> None: + steps, answer, _ = self.positive_case(7) + def mutate(connection): + user_id = connection.execute("SELECT id FROM users WHERE username='david_k'").fetchone()[0] + article_id = connection.execute( + "SELECT a.id FROM articles a JOIN categories c ON c.id=a.category_id WHERE c.slug='nanotechnology' AND a.slug<>? AND NOT EXISTS (SELECT 1 FROM saved_articles s WHERE s.article_id=a.id AND s.user_id=?) LIMIT 1", + (SLUGS["exosomes"], user_id), + ).fetchone()[0] + connection.execute("INSERT INTO saved_articles(user_id,article_id,note,created_at) VALUES(?,?,?,?)", (user_id, article_id, NOTE, "2026-05-14 12:00:00")) + returncode, verdict = self.run_verifier(7, steps, answer, mutate) + self.assertNotEqual(0, returncode) + self.assertFalse(verdict["pass"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/phys_org/verify/verify_0.py b/sites/phys_org/verify/verify_0.py new file mode 100644 index 000000000..5f93f9cfa --- /dev/null +++ b/sites/phys_org/verify/verify_0.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + run_stateless, + visited_in_order, +) + +SLUG = "magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-" + +def checks(t, answer): + return ([ + ("ordered_physics_to_article", visited_in_order(t, [ + ("/category/physics", {}), (f"/article/{SLUG}", {}) + ]), "visited Physics before the target article"), + ("clicked_target_from_physics", clicked_path_transition( + t, "/category/physics", f"/article/{SLUG}" + ), "clicked the target from the category list"), + ], [("answer_source_journal", contains_all(answer, ["Physical Review Letters"]), repr(answer))]) + +if __name__ == "__main__": + run_stateless(0, checks) diff --git a/sites/phys_org/verify/verify_1.py b/sites/phys_org/verify/verify_1.py new file mode 100644 index 000000000..5f240361e --- /dev/null +++ b/sites/phys_org/verify/verify_1.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + run_stateless, + visited_in_order, +) + +SLUG = "quantum-circuit-test-finally-exposes-what-has-been-warping-performance" + +def checks(t, answer): + return ([("ordered_physics_to_article", visited_in_order(t, [ + ("/category/physics", {}), (f"/article/{SLUG}", {}) + ]), "visited Physics Recent before the target article"), + ("clicked_target_from_physics", clicked_path_transition( + t, "/category/physics", f"/article/{SLUG}" + ), "clicked the target from Physics Recent")], + [("answer_provider", contains_all(answer, ["Massachusetts Institute of Technology"]), repr(answer))]) + +if __name__ == "__main__": + run_stateless(1, checks) diff --git a/sites/phys_org/verify/verify_10.py b/sites/phys_org/verify/verify_10.py new file mode 100644 index 000000000..0e88d0e64 --- /dev/null +++ b/sites/phys_org/verify/verify_10.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + has_rank, + run_stateless, + visited_in_order, +) + +SLUG = "how-a-single-star-can-reshape-an-entire-galaxy" +PROVIDER = "European Southern Observatory" + +def checks(t, answer): + return ([ + ("ordered_popular_to_article", visited_in_order(t, [ + ("/category/astronomy", {"sort": "popular"}), + (f"/article/{SLUG}", {}) + ]), "opened the named article from Astronomy Popular"), + ("clicked_named_article", clicked_path_transition( + t, "/category/astronomy", f"/article/{SLUG}" + ), "clicked the named article from Popular"), + ], [ + ("answer_rank", has_rank(answer, 3), repr(answer)), + ("answer_provider", contains_all(answer, ["Leiden University"]), repr(answer)), + ]) + +if __name__ == "__main__": + run_stateless(10, checks) diff --git a/sites/phys_org/verify/verify_11.py b/sites/phys_org/verify/verify_11.py new file mode 100755 index 000000000..c0bd58e0c --- /dev/null +++ b/sites/phys_org/verify/verify_11.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from verify_lib import answers_earlier_comparison, run_stateless, visited_path + +MAGNETIC = "magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-" +EARLIER = "quantum-geometry-applied-to-light-based-systems-expands-toolkit-for-to" +TITLE = "Quantum geometry applied to light-based systems expands toolkit for topological photonics" +OTHER_TITLE = "Magnetic checkerboard separates microparticles by size and sends them along different paths" + +def checks(t, answer): + return ([ + ("nav_magnetic", visited_path(t, f"/article/{MAGNETIC}"), "opened first article"), + ("nav_quantum_geometry", visited_path(t, f"/article/{EARLIER}"), "opened second article"), + ], [("answer_earlier_article", + answers_earlier_comparison(answer, TITLE, OTHER_TITLE), repr(answer))]) + +if __name__ == "__main__": + run_stateless(11, checks) diff --git a/sites/phys_org/verify/verify_12.py b/sites/phys_org/verify/verify_12.py new file mode 100644 index 000000000..4c0df3e89 --- /dev/null +++ b/sites/phys_org/verify/verify_12.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + equivalent_phrase, + run_stateless, + visited_in_order, +) + +SLUG = "jwst-spots-two-early-black-holes-growing-far-faster-than-their-galaxie" +REPLY = "Totally agree on the priors point — the new constraint is much tighter though." + +def checks(t, answer): + return ([("ordered_popular_to_comment_thread", visited_in_order(t, [ + ("/category/astronomy", {"sort": "popular"}), + (f"/article/{SLUG}", {}) + ]), "opened the second Popular article before its comments"), + ("clicked_second_popular", clicked_path_transition( + t, "/category/astronomy", f"/article/{SLUG}" + ), "clicked the second Popular article")], + [("answer_full_reply", equivalent_phrase(answer, REPLY), repr(answer))]) + +if __name__ == "__main__": + run_stateless(12, checks) diff --git a/sites/phys_org/verify/verify_13.py b/sites/phys_org/verify/verify_13.py new file mode 100644 index 000000000..b7a73db62 --- /dev/null +++ b/sites/phys_org/verify/verify_13.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +import re + +import bcrypt +from verify_lib import ( + Judge, + check_common, + contains_all, + db_query, + entered_text, + final_answer, + input_values_at_path, + load_run, + parse_args, + resolve_db, + submitted_from_path, + visited_in_order, +) + +BCRYPT_HASH = re.compile(r"\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}") +ALL_QUERY = """ +SELECT id,username,email,full_name,location,bio,interests,password_hash,created_at +FROM users +ORDER BY id +""" + + +def main(): + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + initial_all = db_query(initial_db, ALL_QUERY) + after_all = db_query(after_db, ALL_QUERY) + initial_ids = set() if initial_all is None else {row[0] for row in initial_all} + new_rows = [] if after_all is None else [ + row for row in after_all if row[0] not in initial_ids + ] + exact_change = ( + initial_all is not None + and len(new_rows) == 1 + and after_all == initial_all + new_rows + ) + new_row = new_rows[0] if len(new_rows) == 1 else None + valid_profile = bool( + new_row + and str(new_row[1]).strip() + and "@" in str(new_row[2]) + and str(new_row[3]).strip() + and new_row[4] == "Berlin, Germany" + ) + valid_password_hash = bool( + new_row and BCRYPT_HASH.fullmatch(str(new_row[7] or "")) + ) + entered_profile = bool(new_row) and all( + entered_text(trajectory, str(value), "/register") + for value in new_row[1:4] + ) + excluded_values = set() if new_row is None else {str(value) for value in new_row[1:4]} + password_candidates = [ + value for value in input_values_at_path(trajectory, "/register") + if value not in excluded_values and len(value) >= 6 + ] + password_matches = valid_password_hash and any( + bcrypt.checkpw(value.encode(), str(new_row[7]).encode()) + for value in password_candidates + ) + + judge = Judge("Phys.org--13") + check_common(judge, trajectory, 13) + judge.check("ordered_registration_flow", visited_in_order(trajectory, [ + ("/register", {}), ("/account", {}) + ]), "visited registration before Account Settings") + judge.check("registration_fields_entered", entered_profile, + "entered the new username, email, and full name") + judge.check("registration_submitted", submitted_from_path(trajectory, "/register"), + "submitted registration form") + judge.check("location_entered", entered_text(trajectory, "Berlin, Germany", "/account"), + "entered requested location") + judge.check("profile_submitted", submitted_from_path(trajectory, "/account", "/account"), + "submitted Account Settings") + judge.check("db_one_new_user_exact", exact_change and valid_profile, + f"new_users={[(row[1], row[2], row[3], row[4]) for row in new_rows]}") + judge.check("db_password_matches_input", bool(password_matches), + "new account hash matches the entered non-empty password") + judge.check("answer_new_username", + bool(new_row) and contains_all(answer, [str(new_row[1])]), + repr(answer)) + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/phys_org/verify/verify_14.py b/sites/phys_org/verify/verify_14.py new file mode 100644 index 000000000..7aee267f3 --- /dev/null +++ b/sites/phys_org/verify/verify_14.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +import re + +from verify_lib import ( + Judge, + check_common, + clicked_path_transition, + contains_all, + db_query, + entered_text, + filled_field, + final_answer, + has_labeled_number, + load_run, + norm, + parse_args, + resolve_db, + submitted_from_path, + visited_in_order, +) + +TARGET_SLUG = "how-a-single-star-can-reshape-an-entire-galaxy" +TOP_REMAINING = "More Star Wars-like worlds emerge as 27 planet candidates with two suns discovered" +QUERY = """ +SELECT s.id,a.slug,a.title,s.note,s.created_at +FROM saved_articles s +JOIN users u ON u.id=s.user_id +JOIN articles a ON a.id=s.article_id +WHERE u.username='alice_j' +ORDER BY s.created_at DESC, s.id DESC +""" +ALL_QUERY = """ +SELECT s.id,u.username,a.slug,s.note,s.created_at +FROM saved_articles s +JOIN users u ON u.id=s.user_id +JOIN articles a ON a.id=s.article_id +ORDER BY s.id +""" + +def main(): + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + initial = db_query(initial_db, QUERY) + after = db_query(after_db, QUERY) + initial_all = db_query(initial_db, ALL_QUERY) + after_all = db_query(after_db, ALL_QUERY) + initial_rows = initial or [] + after_rows = after or [] + expected_after = [row for row in initial_rows if row[1] != TARGET_SLUG] + removed_ids = {row[0] for row in initial_rows if row[1] == TARGET_SLUG} + expected_all_after = None if initial_all is None else [ + row for row in initial_all if row[0] not in removed_ids + ] + judge = Judge("Phys.org--14") + check_common(judge, trajectory, 14) + judge.check("login_as_alice", + filled_field(trajectory, "email", "alice.j@test.com", "/login") + and entered_text(trajectory, "TestPass123!", "/login") + and submitted_from_path(trajectory, "/login"), + "submitted Alice's credentials") + judge.check("ordered_remove_flow", visited_in_order(trajectory, [ + ("/login", {}), (f"/article/{TARGET_SLUG}", {}), ("/saved", {}) + ]), "opened target and then Saved") + judge.check("clicked_target_from_saved", clicked_path_transition( + trajectory, "/saved", f"/article/{TARGET_SLUG}" + ), "clicked the target from Saved") + judge.check("remove_submitted", + submitted_from_path(trajectory, f"/article/{TARGET_SLUG}", + f"/article/{TARGET_SLUG}"), + "submitted removal on target article") + judge.check("db_target_was_saved", any(row[1] == TARGET_SLUG for row in initial_rows), + f"initial_saved={initial_rows}") + judge.check("db_only_target_removed", + initial is not None and after == expected_after + and after_all == expected_all_after, + f"after_saved={after_rows} expected={expected_after}") + judge.check("db_five_remain", len(after_rows) == 5, f"remaining={len(after_rows)}") + judge.check("db_top_remaining", bool(after_rows) and after_rows[0][2] == TOP_REMAINING, + f"top={after_rows[0][2] if after_rows else None}") + normalized = norm(answer) + top_is_bound = contains_all(answer, [TOP_REMAINING]) and re.search( + rf"(?:top|first|most recent).{{0,80}}{re.escape(norm(TOP_REMAINING))}|" + rf"{re.escape(norm(TOP_REMAINING))}.{{0,80}}(?:top|first|most recent)", + normalized, + ) + judge.check("answer_count_and_top", + has_labeled_number(answer, 5, ("remain", "remaining", "saved")) + and bool(top_is_bound), repr(answer)) + judge.emit() + +if __name__ == "__main__": + main() diff --git a/sites/phys_org/verify/verify_15.py b/sites/phys_org/verify/verify_15.py new file mode 100644 index 000000000..e219dc008 --- /dev/null +++ b/sites/phys_org/verify/verify_15.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + has_rank, + run_stateless, + visited_category, + visited_in_order, + visited_path, +) + +SLUG = "good-vibrations-for-quantum-communications-engineers-couple-single-pho" +TITLE = "Good vibrations for quantum communications: Engineers couple single phonon to single atomic spin" + +def checks(t, answer): + return ([ + ("nav_home", visited_path(t, "/"), "visited homepage sidebar"), + ("nav_sixth_trending_article", visited_path(t, f"/article/{SLUG}"), "opened sixth sidebar entry"), + ("nav_physics_popular", visited_category(t, "physics", "popular"), "opened category Popular view"), + ("ordered_full_flow", visited_in_order(t, [ + ("/", {}), (f"/article/{SLUG}", {}), + ("/category/physics", {}), ("/category/physics", {"sort": "popular"}) + ]), "completed the click chain in order"), + ("click_sixth_trending", + clicked_path_transition(t, "/", f"/article/{SLUG}"), + "clicked from home into the third Trending article"), + ("click_article_category", + clicked_path_transition(t, f"/article/{SLUG}", "/category/physics"), + "followed the Physics category link from the article"), + ("click_category_popular", + clicked_path_transition(t, "/category/physics", "/category/physics", + {"sort": "popular"}), + "switched the category view to Popular by click"), + ], [ + ("answer_title_category_rank", + contains_all(answer, [TITLE, "Physics"]) and has_rank(answer, 2), repr(answer)), + ]) + +if __name__ == "__main__": + run_stateless(15, checks) diff --git a/sites/phys_org/verify/verify_16.py b/sites/phys_org/verify/verify_16.py new file mode 100644 index 000000000..75cc63c2a --- /dev/null +++ b/sites/phys_org/verify/verify_16.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + run_stateless, + visited_in_order, +) + +SLUG = "anion-swap-unlocks-sevenfold-co-capture-in-polyionic-liquids" + +def checks(t, answer): + return ([ + ("ordered_filtered_search_to_article", visited_in_order(t, [ + ("/search", {"q": "capture materials", "category": "chemistry"}), + (f"/article/{SLUG}", {}) + ]), "opened the target from filtered results"), + ("clicked_target_from_results", clicked_path_transition( + t, "/search", f"/article/{SLUG}" + ), "clicked the target from filtered results"), + ], [("answer_source_journal", + contains_all(answer, ["Reaction Chemistry & Engineering"]), repr(answer))]) + +if __name__ == "__main__": + run_stateless(16, checks) diff --git a/sites/phys_org/verify/verify_17.py b/sites/phys_org/verify/verify_17.py new file mode 100644 index 000000000..659b4eb3d --- /dev/null +++ b/sites/phys_org/verify/verify_17.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, + check_common, + clicked_path_transition, + contains_all, + db_query, + entered_text, + filled_field, + final_answer, + load_run, + parse_args, + resolve_db, + submitted_from_path, + visited_in_order, +) + +QUERY = """ +SELECT s.query,s.created_at +FROM search_history s +JOIN users u ON u.id=s.user_id +WHERE u.username='alice_j' +ORDER BY s.created_at DESC,s.id DESC +""" +ALL_QUERY = """ +SELECT id,user_id,query,created_at +FROM search_history +ORDER BY id +""" + +def main(): + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + initial = db_query(initial_db, QUERY) + after = db_query(after_db, QUERY) + initial_all = db_query(initial_db, ALL_QUERY) + after_all = db_query(after_db, ALL_QUERY) + judge = Judge("Phys.org--17") + check_common(judge, trajectory, 17) + judge.check("login_as_alice", + filled_field(trajectory, "email", "alice.j@test.com", "/login") + and entered_text(trajectory, "TestPass123!", "/login") + and submitted_from_path(trajectory, "/login"), + "submitted Alice's credentials") + judge.check("ordered_login_to_account", visited_in_order(trajectory, [ + ("/login", {}), ("/account", {}) + ]), "visited Account Settings after login") + judge.check("clicked_account", clicked_path_transition( + trajectory, "/", "/account" + ), "clicked Account Settings after login") + judge.check("db_search_history_unchanged", + initial is not None and after == initial and after_all == initial_all, + f"initial_history={initial} after_history={after}") + judge.check("answer_second_query", contains_all(answer, ["dark matter halo"]), repr(answer)) + judge.emit() + +if __name__ == "__main__": + main() diff --git a/sites/phys_org/verify/verify_2.py b/sites/phys_org/verify/verify_2.py new file mode 100644 index 000000000..a1ebb1d30 --- /dev/null +++ b/sites/phys_org/verify/verify_2.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + run_stateless, + visited_in_order, +) + +SLUG = "method-for-measuring-energy-amounts-less-than-a-trillionth-of-a-billio" + +def checks(t, answer): + return ([ + ("ordered_search_to_article", visited_in_order(t, [ + ("/search", {"q": "quantum"}), (f"/article/{SLUG}", {}) + ]), "searched for quantum before opening the target"), + ("clicked_target_from_search", clicked_path_transition( + t, "/search", f"/article/{SLUG}" + ), "clicked the target from search results"), + ], [("answer_source_journal", contains_all(answer, ["Nature Electronics"]), repr(answer))]) + +if __name__ == "__main__": + run_stateless(2, checks) diff --git a/sites/phys_org/verify/verify_3.py b/sites/phys_org/verify/verify_3.py new file mode 100644 index 000000000..94d72e39c --- /dev/null +++ b/sites/phys_org/verify/verify_3.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + run_stateless, + visited_in_order, +) + +SLUG = "magnetic-checkerboard-separates-microparticles-by-size-and-sends-them-" + +def checks(t, answer): + return ([ + ("ordered_trending_to_rank_three", visited_in_order(t, [ + ("/trending", {}), (f"/article/{SLUG}", {}) + ]), "visited Trending before its third article"), + ("clicked_third_trending", clicked_path_transition( + t, "/trending", f"/article/{SLUG}" + ), "clicked the third Trending result"), + ], [("answer_provider", contains_all(answer, ["University of Tübingen"]), repr(answer))]) + +if __name__ == "__main__": + run_stateless(3, checks) diff --git a/sites/phys_org/verify/verify_4.py b/sites/phys_org/verify/verify_4.py new file mode 100644 index 000000000..71a81e8bc --- /dev/null +++ b/sites/phys_org/verify/verify_4.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + entered_text, + filled_field, + has_labeled_number, + run_stateless, + submitted_from_path, + visited_in_order, +) + + +def checks(t, answer): + return ([ + ("login_as_alice", filled_field(t, "email", "alice.j@test.com", "/login") + and entered_text(t, "TestPass123!", "/login") + and submitted_from_path(t, "/login"), "submitted Alice's credentials"), + ("ordered_login_to_saved", visited_in_order(t, [ + ("/login", {}), ("/saved", {}) + ]), "visited Saved after login"), + ("clicked_saved", clicked_path_transition(t, "/", "/saved"), + "clicked Saved after login"), + ], [("answer_astronomy_count", has_labeled_number( + answer, 4, ("astronomy", "astronomy & space") + ), repr(answer))]) + +if __name__ == "__main__": + run_stateless(4, checks) diff --git a/sites/phys_org/verify/verify_5.py b/sites/phys_org/verify/verify_5.py new file mode 100644 index 000000000..d704416e0 --- /dev/null +++ b/sites/phys_org/verify/verify_5.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + contains_all, + entered_text, + filled_field, + run_stateless, + submitted_from_path, + visited_in_order, +) + +SLUG = "cracking-the-code-of-hypersonic-flight-a-decade-of-experiments-maps-tu" + +def checks(t, answer): + return ([ + ("login_as_bob", filled_field(t, "email", "bob.c@test.com", "/login") + and entered_text(t, "TestPass123!", "/login") + and submitted_from_path(t, "/login"), "submitted Bob's credentials"), + ("ordered_saved_to_article", visited_in_order(t, [ + ("/login", {}), ("/saved", {}), (f"/article/{SLUG}", {}) + ]), "opened the noted article from Saved"), + ("clicked_noted_article", clicked_path_transition( + t, "/saved", f"/article/{SLUG}" + ), "clicked the noted article from Saved"), + ], [("answer_publication_venue", contains_all(answer, ["AIAA SCITECH 2026 Forum"]), repr(answer))]) + +if __name__ == "__main__": + run_stateless(5, checks) diff --git a/sites/phys_org/verify/verify_6.py b/sites/phys_org/verify/verify_6.py new file mode 100644 index 000000000..615ba9373 --- /dev/null +++ b/sites/phys_org/verify/verify_6.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, + check_common, + clicked_path_transition, + contains_all, + db_query, + entered_text, + filled_field, + final_answer, + load_run, + parse_args, + resolve_db, + submitted_from_path, + visited_in_order, + visited_path, +) + +COMMENT = "Reviewed for our weekly journal club" +QUERY = """ +SELECT c.id, a.title, a.slug +FROM comments c +JOIN users u ON u.id=c.user_id +JOIN articles a ON a.id=c.article_id +JOIN categories cat ON cat.id=a.category_id +WHERE u.username='carol_d' AND c.parent_id IS NULL + AND c.text=? AND cat.slug='biology' +""" +ALL_QUERY = """ +SELECT id,text,user_id,article_id,parent_id,score,created_at +FROM comments +ORDER BY id +""" + +def main(): + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + initial = db_query(initial_db, QUERY, (COMMENT,)) + after = db_query(after_db, QUERY, (COMMENT,)) + initial_all = db_query(initial_db, ALL_QUERY) + after_all = db_query(after_db, ALL_QUERY) + initial_ids = set() if initial is None else {row[0] for row in initial} + new_rows = [] if after is None else [row for row in after if row[0] not in initial_ids] + added_full_rows = [] if after_all is None else [ + row for row in after_all if any(row[0] == added[0] for added in new_rows) + ] + exact_change = ( + initial == [] and len(new_rows) == 1 and len(added_full_rows) == 1 + and initial_all is not None and after_all == initial_all + added_full_rows + ) + judge = Judge("Phys.org--6") + check_common(judge, trajectory, 6) + judge.check("login_as_carol", + filled_field(trajectory, "email", "carol.d@test.com", "/login") + and entered_text(trajectory, "TestPass123!", "/login") + and submitted_from_path(trajectory, "/login"), + "submitted Carol's credentials") + judge.check("db_new_top_level_comment", exact_change, f"new_matching_comments={new_rows}") + visited_target = bool(new_rows) and any( + visited_path(trajectory, f"/article/{slug}") for _, _, slug in new_rows + ) + ordered_target = bool(new_rows) and any( + visited_in_order(trajectory, [ + ("/login", {}), ("/category/biology", {}), (f"/article/{slug}", {}) + ]) for _, _, slug in new_rows + ) + judge.check("ordered_biology_to_commented_article", ordered_target, + f"new_matching_comments={new_rows}") + judge.check("clicked_biology_article", bool(new_rows) and any( + clicked_path_transition(trajectory, "/category/biology", f"/article/{slug}") + for _, _, slug in new_rows + ), "clicked the commented article from Biology") + comment_submitted = bool(new_rows) and any( + entered_text(trajectory, COMMENT, f"/article/{slug}") + and submitted_from_path(trajectory, f"/article/{slug}", f"/article/{slug}") + for _, _, slug in new_rows + ) + judge.check("comment_form_submitted", comment_submitted, "exact comment input and submission") + judge.check("nav_commented_article", visited_target, f"new_matching_comments={new_rows}") + answer_matches = bool(new_rows) and any(contains_all(answer, [title]) for _, title, _ in new_rows) + judge.check("answer_article_title", answer_matches, repr(answer)) + judge.emit() + +if __name__ == "__main__": + main() diff --git a/sites/phys_org/verify/verify_7.py b/sites/phys_org/verify/verify_7.py new file mode 100644 index 000000000..2abc4b9ab --- /dev/null +++ b/sites/phys_org/verify/verify_7.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, + check_common, + clicked_path_transition, + contains_all, + db_query, + entered_text, + filled_field, + final_answer, + load_run, + parse_args, + resolve_db, + submitted_from_path, + visited_in_order, + visited_path, +) + +NOTE = "Compare with our process" +TARGET_SLUG = "engineered-exosomes-reverse-sleep-deprivation-brain-damage-in-mice" +INITIAL_QUERY = """ +SELECT a.id +FROM saved_articles s +JOIN users u ON u.id=s.user_id +JOIN articles a ON a.id=s.article_id +WHERE u.username='david_k' +""" +AFTER_QUERY = """ +SELECT s.id, a.id, a.title, a.slug +FROM saved_articles s +JOIN users u ON u.id=s.user_id +JOIN articles a ON a.id=s.article_id +JOIN categories cat ON cat.id=a.category_id +WHERE u.username='david_k' AND s.note=? AND cat.slug='nanotechnology' + AND a.slug=? +""" +ALL_QUERY = """ +SELECT s.id,a.id,a.slug,s.note,s.created_at +FROM saved_articles s +JOIN users u ON u.id=s.user_id +JOIN articles a ON a.id=s.article_id +ORDER BY s.id +""" + +def main(): + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + initial = db_query(initial_db, INITIAL_QUERY) + after = db_query(after_db, AFTER_QUERY, (NOTE, TARGET_SLUG)) + initial_all = db_query(initial_db, ALL_QUERY) + after_all = db_query(after_db, ALL_QUERY) + initial_ids = set() if initial is None else {row[0] for row in initial} + new_rows = [] if after is None else [row for row in after if row[1] not in initial_ids] + added_full_rows = [] if after_all is None else [ + row for row in after_all if any(row[0] == added[0] for added in new_rows) + ] + exact_change = ( + len(new_rows) == 1 and len(added_full_rows) == 1 + and initial_all is not None and after_all == initial_all + added_full_rows + ) + judge = Judge("Phys.org--7") + check_common(judge, trajectory, 7) + judge.check("login_as_david", + filled_field(trajectory, "email", "david.k@test.com", "/login") + and entered_text(trajectory, "TestPass123!", "/login") + and submitted_from_path(trajectory, "/login"), + "submitted David's credentials") + judge.check("ordered_save_flow", visited_in_order(trajectory, [ + ("/login", {}), ("/category/nanotechnology", {}), + (f"/article/{TARGET_SLUG}", {}), ("/saved", {}) + ]), "visited Nanotechnology, target article, then Saved") + judge.check("clicked_target_from_nanotechnology", clicked_path_transition( + trajectory, "/category/nanotechnology", f"/article/{TARGET_SLUG}" + ), "clicked the target from Nanotechnology") + judge.check("nav_saved", visited_path(trajectory, "/saved"), "visited saved list") + judge.check("db_new_saved_article", exact_change, f"new_matching_saves={new_rows}") + visited_target = bool(new_rows) and any( + visited_path(trajectory, f"/article/{slug}") for _, _, _, slug in new_rows + ) + judge.check("nav_saved_article", visited_target, f"new_matching_saves={new_rows}") + judge.check("save_form_submitted", + entered_text(trajectory, NOTE, f"/article/{TARGET_SLUG}") + and submitted_from_path(trajectory, f"/article/{TARGET_SLUG}", + f"/article/{TARGET_SLUG}"), + "exact note input and save submission") + answer_matches = bool(new_rows) and contains_all(answer, ["Bio & Medicine"]) + judge.check("answer_article_subsection", answer_matches, repr(answer)) + judge.emit() + +if __name__ == "__main__": + main() diff --git a/sites/phys_org/verify/verify_8.py b/sites/phys_org/verify/verify_8.py new file mode 100644 index 000000000..ea0eb696d --- /dev/null +++ b/sites/phys_org/verify/verify_8.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from verify_lib import ( + clicked_path_transition, + has_labeled_number, + run_stateless, + visited_in_order, +) + + +def checks(t, answer): + return ([("ordered_members_to_carol", visited_in_order(t, [ + ("/users", {}), ("/user/carol_d", {}) + ]), "opened Carol's profile from Members"), + ("clicked_carol_profile", clicked_path_transition( + t, "/users", "/user/carol_d" + ), "clicked Carol's profile from Members")], + [("answer_comment_count", has_labeled_number(answer, 3, ("comment", "comments")), repr(answer))]) + +if __name__ == "__main__": + run_stateless(8, checks) diff --git a/sites/phys_org/verify/verify_9.py b/sites/phys_org/verify/verify_9.py new file mode 100644 index 000000000..5fc7d17ab --- /dev/null +++ b/sites/phys_org/verify/verify_9.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import re + +from verify_lib import ( + answers_earlier_comparison, + clicked_path_transition, + norm, + run_stateless, + visited_in_order, +) + +RECENT = "machine-learning-proves-that-graphene-is-hydrophobic" +EARLIER = "hourglass-nanographenes-unlock-strong-robust-multi-spin-entanglement" +RECENT_TITLE = "Machine learning proves that graphene is hydrophobic" +EARLIER_TITLE = "Hourglass nanographenes unlock strong, robust multi-spin entanglement" + +def answer_binds_winner_and_journal(answer): + normalized = norm(answer) + expected = re.escape(norm(EARLIER_TITLE)) + journal = re.escape("nature synthesis") + direct = re.search(rf"{expected}\s*(?:[-—:;,])\s*{journal}\b", normalized) + related = re.search( + rf"{expected}.{{0,100}}\b(?:earlier|predates?)\b.{{0,50}}" + rf"(?:journal\s*(?:is|:)?|[-—:;,])\s*{journal}\b", + normalized, + ) + reverse = re.search( + rf"{journal}.{{0,50}}(?:journal.{{0,20}})?{expected}", normalized + ) + return answers_earlier_comparison(answer, EARLIER_TITLE, RECENT_TITLE) and bool( + direct or related or reverse + ) + + +def checks(t, answer): + search_to_recent = visited_in_order(t, [ + ("/search", {"q": "graphene spin"}), (f"/article/{RECENT}", {}) + ]) + search_to_earlier = visited_in_order(t, [ + ("/search", {"q": "graphene spin"}), (f"/article/{EARLIER}", {}) + ]) + clicked_both = ( + clicked_path_transition(t, "/search", f"/article/{RECENT}") + and clicked_path_transition(t, "/search", f"/article/{EARLIER}") + ) + return ([ + ("search_precedes_both_articles", search_to_recent and search_to_earlier, + "searched graphene spin before opening both articles"), + ("clicked_both_results", clicked_both, + "clicked both named articles from search results"), + ], [("answer_earlier_article_and_journal", + answer_binds_winner_and_journal(answer), repr(answer))]) + +if __name__ == "__main__": + run_stateless(9, checks) diff --git a/sites/phys_org/verify/verify_lib.py b/sites/phys_org/verify/verify_lib.py new file mode 100755 index 000000000..7603614f8 --- /dev/null +++ b/sites/phys_org/verify/verify_lib.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +"""Shared deterministic utilities for Phys.org task verifiers.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import re +import sqlite3 +import subprocess +import tempfile +import unicodedata +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +SITE = "phys_org" + + +def load_run(run_dir: str | Path) -> dict: + path = Path(run_dir) / "trajectory.json" + trajectory = json.loads(path.read_text(encoding="utf-8")) + trajectory["_run_dir"] = str(Path(run_dir)) + return trajectory + + +def step_urls(trajectory: dict) -> list[str]: + return [str(step.get("url", "")) for step in trajectory.get("steps", [])] + + +def _is_loopback_host(hostname: str) -> bool: + if hostname.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _is_mirror_url(url: str, trajectory: dict) -> bool: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return False + start = urlparse(str(trajectory.get("start_url") or "")) + if not start.hostname or not _is_loopback_host(start.hostname): + return False + return ( + _is_loopback_host(parsed.hostname) + and parsed.scheme == start.scheme + and parsed.port == start.port + ) + + +def visited_path(trajectory: dict, path: str) -> bool: + return any(_is_mirror_url(url, trajectory) and urlparse(url).path == path + for url in step_urls(trajectory)) + + +def visited_search(trajectory: dict, query: str, category: str | None = None) -> bool: + for url in step_urls(trajectory): + if not _is_mirror_url(url, trajectory): + continue + parsed = urlparse(url) + if parsed.path != "/search": + continue + params = parse_qs(parsed.query) + if norm((params.get("q") or [""])[0]) != norm(query): + continue + if category is not None and norm((params.get("category") or [""])[0]) != norm(category): + continue + return True + return False + + +def visited_category(trajectory: dict, slug: str, sort: str | None = None) -> bool: + path = f"/category/{slug}" + for url in step_urls(trajectory): + if not _is_mirror_url(url, trajectory): + continue + parsed = urlparse(url) + if parsed.path != path: + continue + if sort is None: + return True + params = parse_qs(parsed.query) + if norm((params.get("sort") or [""])[0]) == norm(sort): + return True + return False + + +def visited_in_order(trajectory: dict, + requirements: list[tuple[str, dict[str, str]]]) -> bool: + """Require URL path/query checkpoints to appear in trajectory order.""" + urls = step_urls(trajectory) + cursor = 0 + for path, expected_query in requirements: + matched = False + for index in range(cursor, len(urls)): + if not _is_mirror_url(urls[index], trajectory): + continue + parsed = urlparse(urls[index]) + params = parse_qs(parsed.query) + query_matches = all( + norm((params.get(key) or [""])[0]) == norm(value) + for key, value in expected_query.items() + ) + if parsed.path == path and query_matches: + cursor = index + 1 + matched = True + break + if not matched: + return False + return True + + +def _transition_pairs(trajectory: dict): + """Yield same-origin action transitions from adjacent steps or url_after.""" + steps = trajectory.get("steps", []) + for index, current in enumerate(steps): + current_url = str(current.get("url", "")) + if not _is_mirror_url(current_url, trajectory): + continue + candidates = [] + if current.get("url_after"): + candidates.append(str(current["url_after"])) + elif index + 1 < len(steps): + candidates.append(str(steps[index + 1].get("url", ""))) + for next_url in candidates: + if _is_mirror_url(next_url, trajectory): + yield norm(current.get("action")), current_url, next_url + + +def clicked_path_transition(trajectory: dict, from_path: str, to_path: str, + to_query: dict[str, str] | None = None) -> bool: + """Require a same-origin transition caused by a click action.""" + expected_query = to_query or {} + for action, current_url, next_url in _transition_pairs(trajectory): + if action != "click" or urlparse(current_url).path != from_path: + continue + parsed_following = urlparse(next_url) + if parsed_following.path != to_path: + continue + params = parse_qs(parsed_following.query) + if all(norm((params.get(key) or [""])[0]) == norm(value) + for key, value in expected_query.items()): + return True + return False + + +def final_answer(trajectory: dict) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def task_id_matches(trajectory: dict, task_number: int) -> bool: + return str(trajectory.get("task_id") or "").strip() == f"Phys.org--{task_number}" + + +def input_values_at_path(trajectory: dict, path: str | None = None) -> list[str]: + values = [] + for step in trajectory.get("steps", []): + if norm(step.get("action")) not in {"fill", "type", "input"}: + continue + step_url = str(step.get("url", "")) + if not _is_mirror_url(step_url, trajectory): + continue + if path is not None and urlparse(step_url).path != path: + continue + params = step.get("params") or {} + values.append(str(params.get("text", params.get("value", "")))) + return values + + +def entered_text(trajectory: dict, expected: str, path: str | None = None) -> bool: + """Require an exact value in a recorded input/fill/type action.""" + expected_normalized = norm(expected) + return any( + norm(value) == expected_normalized + for value in input_values_at_path(trajectory, path) + ) + + +def submitted_from_path(trajectory: dict, from_path: str, + to_path: str | None = None) -> bool: + """Require a click submission followed by a same-origin response page.""" + for action, current_url, next_url in _transition_pairs(trajectory): + if action != "click" or urlparse(current_url).path != from_path: + continue + next_path = urlparse(next_url).path + if to_path is None and next_path != from_path: + return True + if to_path is not None and next_path == to_path: + return True + return False + + +def filled_field(trajectory: dict, field: str, expected: str, + path: str | None = None) -> bool: + """Return whether a named field's final recorded value matches exactly. + + Legacy probe trajectories identify fields by CSS selector. The repository + runner records only ``input(index, text)``. The fixed login page has a + global search input before the form, then email and password; among the + form inputs used to authenticate, email is therefore the penultimate DOM + index. In both schemas the last value for that field wins, so an + overwritten credential cannot pass. + """ + field_pattern = re.compile(rf"(?:name\s*=\s*['\"]?{re.escape(field)}\b|#{re.escape(field)}\b)", + re.IGNORECASE) + legacy_values: list[str] = [] + indexed_values: dict[int, list[str]] = {} + for step in trajectory.get("steps", []): + action = norm(step.get("action")) + if action not in {"fill", "type", "input"}: + continue + step_url = str(step.get("url", "")) + if not _is_mirror_url(step_url, trajectory): + continue + if path is not None and urlparse(step_url).path != path: + continue + params = step.get("params") or {} + value = params.get("text", params.get("value", "")) + if action in {"fill", "type"}: + selector = str(params.get("css") or params.get("selector") or "") + if field_pattern.search(selector): + legacy_values.append(norm(value)) + continue + try: + index = int(params.get("index")) + except (TypeError, ValueError): + continue + indexed_values.setdefault(index, []).append(norm(value)) + if legacy_values: + return legacy_values[-1] == norm(expected) + if field == "email" and len(indexed_values) >= 2: + email_index = sorted(indexed_values)[-2] + return indexed_values[email_index][-1] == norm(expected) + return False + + +def norm(value: object) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + return re.sub(r"\s+", " ", text).strip().casefold() + + +NEGATION_WORDS = { + "not", "no", "never", "without", "isn't", "isnt", "aren't", "arent", + "wasn't", "wasnt", "weren't", "werent", "doesn't", "doesnt", "didn't", + "didnt", +} + + +def _negated_at(normalized: str, start: int) -> bool: + prefix = normalized[:start] + clause = re.split( + r"(?:[.!?;:\n]+|\b(?:and|but|however|instead)\b)", prefix + )[-1] + if re.fullmatch(r"\s*no\s*,\s*", clause): + return False + prefix_words = re.findall(r"[a-z0-9]+(?:['’][a-z]+)?", clause) + return any(word in NEGATION_WORDS for word in prefix_words) + + +def _denied_after(normalized: str, end: int) -> bool: + """Detect a direct post-value denial such as ``X is not the answer``.""" + suffix = normalized[end:] + for _ in range(4): + stripped = re.sub( + r"^\s*(?:[-—–,:;!?]+|\bhowever\b)\s*", "", suffix + ) + if stripped == suffix: + break + suffix = stripped + return re.match( + r"\s*(?:no\b|(?:[a-z]+\s+){1,3}(?:not|never|no)\b|" + r"(?:isn't|isnt|aren't|arent|wasn't|wasnt|" + r"weren't|werent|doesn't|doesnt|don't|dont|didn't|didnt|can't|" + r"cant|couldn't|couldnt|wouldn't|wouldnt|shouldn't|shouldnt)\b)", + suffix, + ) is not None + + +def _contains_affirmatively(text: str, expected: object) -> bool: + normalized = norm(text) + needle = norm(expected) + if not needle: + return False + matches = list(re.finditer(re.escape(needle), normalized)) + if not matches: + return False + last = matches[-1] + return ( + not _negated_at(normalized, last.start()) + and not _denied_after(normalized, last.end()) + ) + + +def contains_all(text: str, expected: list[str] | tuple[str, ...]) -> bool: + return all(_contains_affirmatively(text, item) for item in expected) + + +def contains_any(text: str, expected: list[str] | tuple[str, ...]) -> bool: + return any(_contains_affirmatively(text, item) for item in expected) + + +def has_number(text: str, value: int) -> bool: + normalized = norm(text) + matches = list(re.finditer(rf"(? bool: + normalized = norm(text) + cardinal_words = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six"} + alternatives = [str(value)] + if value in cardinal_words: + alternatives.append(cardinal_words[value]) + number_pattern = rf"(? bool: + normalized = norm(text) + ordinal_words = {1: "first", 2: "second", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth"} + ordinal = ordinal_words.get(value, str(value)) + patterns = [ + rf"\b(?:rank|ranked|ranks|position|positioned)\s*(?:is\s*)?(?:number\s*)?(?:#?\s*{value}|{ordinal})\b", + rf"\b{value}(?:st|nd|rd|th)\s+(?:place|position|rank|result)\b", + rf"\b{ordinal}\s+(?:place|position|rank|result)\b", + ] + return any(re.search(pattern, normalized) for pattern in patterns) + + +def equivalent_phrase(text: str, expected: str) -> bool: + """Compare full text while normalizing whitespace and dash punctuation.""" + def canonical(value: str) -> str: + normalized = unicodedata.normalize("NFKC", value) + normalized = re.sub(r"[-‐‑‒–—―]", "-", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip().casefold() + return normalized.strip(" .!?'\"") + return _contains_affirmatively(canonical(text), canonical(expected)) + + +def claims_earlier(text: str, expected_title: str, + other_title: str | None = None) -> bool: + """Require an affirmative earlier/older claim and reject reversed wording.""" + if not _contains_affirmatively(text, expected_title): + return False + return _relation_for_title(text, expected_title, other_title) is True + + +def answers_earlier_comparison(text: str, expected_title: str, other_title: str) -> bool: + """Accept an explicit earlier claim or an unambiguous direct-title answer. + + A prompt that asks which of two named items is earlier can be answered with + the winning title alone. If both titles are repeated, relational wording is + still required so the answer cannot pass while remaining ambiguous. + """ + relation = _relation_for_title(text, expected_title, other_title) + if _contains_affirmatively(text, expected_title) and relation is True: + return True + if relation is False: + return False + normalized = norm(text) + return ( + _contains_affirmatively(text, expected_title) + and norm(other_title) not in normalized + ) + + +def _relation_for_title(text: str, expected_title: str, + other_title: str | None = None) -> bool | None: + """Return the relation claim local to a title, bounded by its comparator.""" + normalized = norm(text) + expected = norm(expected_title) + matches = list(re.finditer(re.escape(expected), normalized)) + if not matches: + return None + target = matches[-1] + other = norm(other_title) if other_title else "" + comparators = list(re.finditer(re.escape(other), normalized)) if other else [] + if comparators: + comparator = min( + comparators, + key=lambda match: min( + abs(match.end() - target.start()), + abs(match.start() - target.end()), + ), + ) + pair_first, pair_second = sorted( + (target, comparator), key=lambda match: match.start() + ) + expected_is_first = pair_first is target + between = normalized[pair_first.end():pair_second.start()] + between_claims = _earlier_relation_claims(between) + if between_claims and re.search(r"\bthan\s*$", between): + applies_to_expected = between_claims[-1] + return applies_to_expected if expected_is_first else not applies_to_expected + + sentence_end_match = re.search(r"[.!?;\n]", normalized[pair_second.end():]) + sentence_end = ( + pair_second.end() + sentence_end_match.start() + if sentence_end_match else len(normalized) + ) + after_pair = normalized[pair_second.end():sentence_end] + after_claims = _earlier_relation_claims(after_pair) + if re.search(r"\bnot\b", between) and after_claims: + applies_to_expected = after_claims[-1] + return applies_to_expected if expected_is_first else not applies_to_expected + reference = re.search(r"\b(former|latter)\b", after_pair) + if reference and after_claims: + refers_to_first = reference.group(1) == "former" + refers_to_expected = refers_to_first == expected_is_first + return after_claims[-1] if refers_to_expected else not after_claims[-1] + + left = 0 + right = len(normalized) + for boundary in re.finditer(r"[.!?;\n]+", normalized): + if boundary.end() <= target.start(): + left = max(left, boundary.end()) + elif boundary.start() >= target.end(): + right = min(right, boundary.start()) + break + if other: + for comparator in re.finditer(re.escape(other), normalized): + if comparator.end() <= target.start(): + left = max(left, comparator.end()) + elif comparator.start() >= target.end(): + right = min(right, comparator.start()) + break + claims = _earlier_relation_claims(normalized[left:right]) + return claims[-1] if claims else None + + +def _earlier_relation_claims(normalized: str) -> list[bool]: + """Return ordered relation claims; True means the answer asserts earlier.""" + claims: list[tuple[int, bool]] = [] + for match in re.finditer(r"\b(?:earlier|older|first|before|predates?)\b", normalized): + claims.append((match.start(), not _negated_at(normalized, match.start()))) + for match in re.finditer(r"\b(?:later|newer|after)\b", normalized): + claims.append((match.start(), _negated_at(normalized, match.start()))) + return [supports_earlier for _, supports_earlier in sorted(claims)] + + +def fetch_db(container: str, kind: str) -> str: + source = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + descriptor, path = tempfile.mkstemp(suffix=".db") + os.close(descriptor) + result = subprocess.run(["docker", "cp", source, path], capture_output=True, + text=True, check=False) + if result.returncode != 0: + Path(path).unlink(missing_ok=True) + raise RuntimeError(f"docker cp {source} failed: {result.stderr.strip()}") + return path + + +def resolve_db(path: str, container: str, kind: str) -> str | None: + if path: + return path + try: + return fetch_db(container, kind) + except Exception: + return None + + +def db_query(path: str | None, sql: str, params: tuple = ()) -> list[tuple] | None: + if not path: + return None + connection = sqlite3.connect(path) + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +class Judge: + def __init__(self, task_id: str): + self.task_id = task_id + self.ok = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str = "") -> bool: + if condition: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(condition) + + def emit(self) -> None: + print(json.dumps({ + "task_id": self.task_id, + "pass": self.ok, + "reason": self.reason, + "evidence": self.evidence, + }, ensure_ascii=False, indent=2)) + raise SystemExit(0 if self.ok else 1) + + +def _bool_value(value: str) -> bool: + return value.casefold() in {"1", "true", "yes", "on"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db", default="") + parser.add_argument("--after_db", default="") + parser.add_argument("--container", default=os.environ.get("WH_CONTAINER", "wh-review")) + parser.add_argument("--no_llm", type=_bool_value, default=False) + return parser.parse_args() + + +def check_common(judge: Judge, trajectory: dict, task_number: int) -> None: + judge.check("task_id_matches", task_id_matches(trajectory, task_number), + f"observed={trajectory.get('task_id')!r}") + judge.check("final_answer_nonempty", bool(final_answer(trajectory)), + repr(final_answer(trajectory))) + + +def stateless_main(task_number: int, trajectory: dict, + navigation_checks: list[tuple[str, bool, str]], + answer_checks: list[tuple[str, bool, str]]) -> None: + judge = Judge(f"Phys.org--{task_number}") + check_common(judge, trajectory, task_number) + for name, condition, evidence in navigation_checks + answer_checks: + judge.check(name, condition, evidence) + judge.emit() + + +def run_stateless(task_number: int, check_builder) -> None: + args = parse_args() + trajectory = load_run(args.run_dir) + navigation, answers = check_builder(trajectory, final_answer(trajectory)) + stateless_main(task_number, trajectory, navigation, answers) diff --git a/websyn_start.sh b/websyn_start.sh index 733232332..b3b5a1619 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all 17 mirror sites, then exec the original CMD. +# WebSyn startup: launch all mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster ikea) + cambridge_dictionary coursera espn merriam_webster ikea phys_org) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,9 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 17 sites on ports ${BASE_PORT}-$((BASE_PORT + 16))..." +SITE_COUNT=${#SITES[@]} +END_PORT=$((BASE_PORT + SITE_COUNT - 1)) +echo "[WebSyn] Starting ${SITE_COUNT} sites on ports ${BASE_PORT}-${END_PORT}..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +53,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/17 sites ready" - if [ $ready -eq 17 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${SITE_COUNT} sites ready" + if [ $ready -eq $SITE_COUNT ]; then break fi done @@ -78,6 +80,6 @@ done echo "[WebSyn] Starting control server on :8101 (PID 1)..." # Control server becomes PID 1 — receives SIGTERM on `docker stop`, -# keeps the container alive as long as it's running. The 17 site +# keeps the container alive as long as it's running. The site # subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101