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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/verified-demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Verified local demo

on:
pull_request:
push:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: python -m unittest discover -s tests -v
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:browser
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- name: Record candidate demo
if: ${{ hashFiles('docs/demo/capture.json') == '' }}
run: node scripts/record_demo.mjs
- name: Verify published demo
run: python scripts/verify_demo.py
- uses: actions/upload-artifact@v4
with:
name: aich-demo-${{ github.event.pull_request.head.sha || github.sha }}
path: docs/demo/
if-no-files-found: error
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
__pycache__/
*.sqlite3
*.sqlite3-*
artifacts/
node_modules/
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,27 @@ python app.py
python -m unittest discover -s tests -v
```

Choose BTC, ETH or SOL to explore a 90-day price chart, total return, annualized sample volatility and maximum drawdown. Add a research note; it survives server restarts in `workspace.sqlite3`. Set `--db /path/to/file.sqlite3` for another workspace or `--port 9000` for another port.
Choose BTC, ETH or SOL to explore a 90-day price chart, total return, annualized sample volatility and maximum drawdown. Add or delete a research note; both changes survive server restarts in `workspace.sqlite3`. Set `--db /path/to/file.sqlite3` for another workspace or `--port 9000` for another port.

## What the demo actually does

![AICH local research workflow](docs/demo/aich-workspace.gif)

[Download the MP4 demo](docs/demo/aich-workspace.mp4) · [Recording receipt](docs/demo/capture.json)

The recorded workflow switches between synthetic fixture assets, opens the metric methodology, saves a local SQLite note, reloads to prove persistence, deletes it, and reloads again to prove removal. The recording is pinned to the exact application source in its receipt and is checked for source drift and complete GIF/MP4 decoding in CI.

The dashboard reads the same HTTP API a client can use:

```sh
curl 'http://127.0.0.1:8080/api/analysis?symbol=BTC'
curl http://127.0.0.1:8080/api/news
curl -H 'Content-Type: application/json' -d '{"author":"Researcher","body":"Compare volatility with drawdown."}' http://127.0.0.1:8080/api/posts
curl http://127.0.0.1:8080/api/posts
curl -X DELETE http://127.0.0.1:8080/api/posts/1
```

The API validates text limits, persists notes with parameterized SQLite statements, returns JSON errors, blocks cross-origin browser writes and serves only allowlisted static files. User text is rendered as text, never HTML.
The API validates text limits and post identifiers, persists note additions/deletions with parameterized SQLite statements, returns JSON errors, blocks cross-origin browser writes and serves only allowlisted static files. User text is rendered as text, never HTML.

**All prices and methodology notes are explicitly synthetic fixtures, dated January 1, 2025.** Risk metrics are calculated from those fixtures. There is no live market feed, LLM prediction model, scraped news, wallet or trading path. This is a local research tool, not an authenticated multi-user service. It binds to loopback only; do not expose it publicly without authentication, limits, CSRF protection and a production HTTP server.

Expand All @@ -35,4 +42,4 @@ Before calling this an AI or live-market product, select a licensed market/news

## Recording walkthrough

Start with a clean `--db /tmp/aich-demo.sqlite3`, open the dashboard, switch assets, expand the methodology, save a note and reload to show persistence. Keep the synthetic-data badge visible. Record only after the tests and browser workflow pass at the exact recorded commit.
Start with a clean `--db /tmp/aich-demo.sqlite3`, open the dashboard, switch assets, expand the methodology, save a note, reload to show persistence, then delete and reload to prove removal. Keep the synthetic-data badge visible. Record only after the tests and browser workflow pass at the exact recorded commit.
26 changes: 24 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ def send(self, status, payload, content_type='application/json'):
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'")
self.send_header('X-Frame-Options', 'DENY')
self.end_headers()
self.wfile.write(body)

Expand All @@ -58,7 +59,8 @@ def do_GET(self):
if path == '/api/health':
return self.send(200, {'ok': True, 'mode': 'local_fixture'})
if path == '/api/analysis':
symbol = parse_qs(parsed.query).get('symbol', ['BTC'])[0].upper()
params = parse_qs(parsed.query, keep_blank_values=True)
symbol = params.get('symbol', ['BTC'])[0].strip().upper()
if symbol not in ASSETS:
return self.send(400, {'error':'Supported assets: BTC, ETH, SOL'})
return self.send(200, analysis(symbol))
Expand Down Expand Up @@ -103,6 +105,26 @@ def do_POST(self):
with connect(db) as conn:
cur = conn.execute('INSERT INTO posts(author,body,created_at) VALUES(?,?,?)', (author,body,datetime.now(timezone.utc).isoformat()))
return self.send(201, {'id':cur.lastrowid})

def do_DELETE(self):
parsed = urlparse(self.path)
prefix = '/api/posts/'
if not parsed.path.startswith(prefix):
return self.send(404, {'error':'Not found'})
origin = self.headers.get('Origin')
if origin and origin != 'http://' + self.headers.get('Host',''):
return self.send(403, {'error':'Cross-origin writes are disabled'})
raw_id = parsed.path[len(prefix):]
if not raw_id.isascii() or not raw_id.isdecimal() or len(raw_id) > 19:
return self.send(400, {'error':'Post id must be a positive integer'})
post_id = int(raw_id)
if not 1 <= post_id <= 9223372036854775807:
return self.send(400, {'error':'Post id must be a positive integer'})
with connect(db) as conn:
deleted = conn.execute('DELETE FROM posts WHERE id = ?', (post_id,)).rowcount
if not deleted:
return self.send(404, {'error':'Post not found'})
return self.send(200, {'deleted_id':post_id})
return Handler


Expand Down
Binary file added docs/demo/aich-workspace-poster.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/demo/aich-workspace.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/demo/aich-workspace.mp4
Binary file not shown.
27 changes: 27 additions & 0 deletions docs/demo/capture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"schema": "aich-demo/v1",
"scope": "Provider-free synthetic fixture; local SQLite note lifecycle; no live market, model, wallet or trading path.",
"source_commit": "215aca74f199362443bf71e53cdca9d5727f1de3",
"source_blobs": {
"app.py": "7ab505d9c2f567eb8aeaed690bb3336df8862b37",
"static/app.js": "e77153d17c069d0cdb69519607dd6379fa7b58cb",
"static/index.html": "58b41352289cb0f2bed3fb843706057ba1673da3",
"static/style.css": "1d5e142876cf60baf24348afeb9e050bac6e02fd",
"scripts/browser_e2e.mjs": "5efd49a1b892fba752cf949012b858252ce9e276",
"scripts/record_demo.mjs": "b2922a751f6ce807c4643830f8b4bf7724da4e3a"
},
"artifacts": {
"aich-workspace.gif": {
"bytes": 2401722,
"sha256": "402ffa1d5f63388fa485dcde51fbbc9b2846af319322fca23d0776f75ea6e779"
},
"aich-workspace.mp4": {
"bytes": 247017,
"sha256": "8d7f89a1d1fa690805b4d193fcfc47c25284849bd8b65eef90bbcaefd29b44f7"
},
"aich-workspace-poster.png": {
"bytes": 82265,
"sha256": "6ef943c9a1022721caf3f24276d873b70773e594c68ee063ffa0b60d9821a3af"
}
}
}
60 changes: 60 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "aich-browser-verification",
"private": true,
"scripts": {
"test:browser": "node scripts/browser_e2e.mjs"
},
"devDependencies": {
"playwright": "1.62.1"
}
}
74 changes: 74 additions & 0 deletions scripts/browser_e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';

const root = new URL('..', import.meta.url).pathname;
const temporary = await mkdtemp(join(tmpdir(), 'aich-browser-'));
const output = join(root, 'artifacts');
await mkdir(output, { recursive: true });

function startServer() {
const child = spawn('python', ['app.py', '--port', '0', '--db', join(temporary, 'workspace.sqlite3')], {
cwd: root,
stdio: ['ignore', 'pipe', 'inherit'],
});
return new Promise((resolve, reject) => {
child.once('error', reject);
child.stdout.once('data', chunk => resolve({ child, url: chunk.toString().trim().split(' ').at(-1) }));
});
}

const { child, url } = await startServer();
const browser = await chromium.launch({
headless: true,
executablePath: process.env.AICH_BROWSER_PATH || undefined,
});

try {
const desktop = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const pageErrors = [];
desktop.on('pageerror', error => pageErrors.push(error.message));
await desktop.goto(url, { waitUntil: 'networkidle' });
await desktop.selectOption('#asset', 'ETH');
await desktop.getByText('Ethereum / ETH').waitFor();
assert.match(await desktop.locator('#volatility').textContent(), /%$/);
await desktop.locator('details summary').click();
assert.equal(await desktop.locator('details').getAttribute('open'), '');

const author = 'Researcher <img src=x onerror=globalThis.__aichXss=true>';
const body = '<script>globalThis.__aichXss=true</script> Compare volatility with drawdown before drawing a conclusion.';
await desktop.locator('input[name=author]').fill(author);
await desktop.locator('textarea[name=body]').fill(body);
await desktop.locator('#post-form button').click();
await desktop.getByText('Saved to your local workspace.').waitFor();
await desktop.getByText(body, { exact: true }).waitFor();
assert.equal(await desktop.evaluate(() => globalThis.__aichXss), undefined);

await desktop.reload({ waitUntil: 'networkidle' });
await desktop.getByText(body, { exact: true }).waitFor();
await desktop.getByRole('button', { name: `Delete note by ${author}` }).click();
await desktop.getByText('Note deleted from your local workspace.').waitFor();
assert.equal(await desktop.getByText(body, { exact: true }).count(), 0);
await desktop.reload({ waitUntil: 'networkidle' });
assert.equal(await desktop.getByText(body, { exact: true }).count(), 0);
await desktop.getByText('No saved notes in this local workspace.', { exact: true }).waitFor();
await desktop.screenshot({ path: join(output, 'desktop.png'), fullPage: true });
assert.deepEqual(pageErrors, []);

const mobile = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 });
await mobile.goto(url, { waitUntil: 'networkidle' });
await mobile.selectOption('#asset', 'SOL');
await mobile.getByText('Solana / SOL').waitFor();
const overflow = await mobile.evaluate(() => document.documentElement.scrollWidth - innerWidth);
assert.ok(overflow <= 0, `mobile horizontal overflow: ${overflow}px`);
await mobile.screenshot({ path: join(output, 'mobile.png'), fullPage: true });
console.log('Browser E2E passed: asset switching, methodology, safe note persistence, deletion persistence, desktop/mobile layout.');
} finally {
await browser.close();
child.kill('SIGTERM');
await new Promise(resolve => child.once('exit', resolve));
await rm(temporary, { recursive: true, force: true });
}
Loading
Loading