first commit of v.02 application
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.bak
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-*
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
*.tgz
|
||||
.yarn-integrity
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# yt-dlp subtitle artifacts
|
||||
*.json3
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.min.css
|
||||
package-lock.json
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"proseWrap": "always",
|
||||
"printWidth": 80
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 @sjdev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
3
LLM_DEV_PROMPTS/.dockerignore
Normal file
3
LLM_DEV_PROMPTS/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
node_modules
|
||||
.DS_Store
|
||||
1541
LLM_DEV_PROMPTS/ANGULAR PROJECTS/ANGULAR_BEST_PRACTICES.md
Normal file
1541
LLM_DEV_PROMPTS/ANGULAR PROJECTS/ANGULAR_BEST_PRACTICES.md
Normal file
File diff suppressed because it is too large
Load Diff
47
LLM_DEV_PROMPTS/ANGULAR PROJECTS/CODE_STYLEGUIDE_ANGULAR.md
Normal file
47
LLM_DEV_PROMPTS/ANGULAR PROJECTS/CODE_STYLEGUIDE_ANGULAR.md
Normal file
@@ -0,0 +1,47 @@
|
||||
You are an expert in TypeScript, Angular, and scalable web application development. You write functional, maintainable, performant, and accessible code following Angular and TypeScript best practices.
|
||||
|
||||
## TypeScript Best Practices
|
||||
1. Use strict type checking
|
||||
2. Prefer type inference when the type is obvious
|
||||
3. Avoid the `any` type; use `unknown` when type is uncertain
|
||||
|
||||
## Angular Best Practices
|
||||
1. Always use standalone components over NgModules
|
||||
2. Must NOT set `standalone: true` inside Angular decorators. It's the default in Angular v20+.
|
||||
3. Use signals for state management
|
||||
4. Implement lazy loading for feature routes
|
||||
5. Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
|
||||
6. Use `NgOptimizedImage` for all static images.
|
||||
7. Note: `NgOptimizedImage` does not work for inline base64 images.
|
||||
|
||||
## Accessibility Requirements
|
||||
1. It MUST pass all AXE checks.
|
||||
2. It MUST follow all WCAG AA minimums, including focus management, color contrast, and ARIA attributes.
|
||||
|
||||
### Components
|
||||
1. Keep components small and focused on a single responsibility
|
||||
2. Use `input()` and `output()` functions instead of decorators
|
||||
3. Use `computed()` for derived state
|
||||
4. Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
|
||||
5. Prefer inline templates for small components
|
||||
6. Prefer Reactive forms instead of Template-driven ones
|
||||
7. Do NOT use `ngClass`, use `class` bindings instead
|
||||
8. Do NOT use `ngStyle`, use `style` bindings instead
|
||||
9. When using external templates/styles, use paths relative to the component TS file.
|
||||
|
||||
## State Management
|
||||
1. Use signals for local component state
|
||||
2. Use `computed()` for derived state
|
||||
3. Keep state transformations pure and predictable
|
||||
4. Do NOT use `mutate` on signals, use `update` or `set` instead
|
||||
|
||||
## Templates
|
||||
1. Keep templates simple and avoid complex logic
|
||||
2. Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
|
||||
3. Use the async pipe to handle observables
|
||||
4. Do not assume globals like (`new Date()`) are available.
|
||||
|
||||
## Services
|
||||
1. Design services around a single responsibility
|
||||
2. Use the `providedIn: 'root'` option for singleton services
|
||||
3. Use the `inject()` function instead of constructor injection
|
||||
79
LLM_DEV_PROMPTS/A_EXISTING_REPO_CHECKLIST.md
Normal file
79
LLM_DEV_PROMPTS/A_EXISTING_REPO_CHECKLIST.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Existing Repo Checklist
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
Use this checklist when starting work in a repo that may not conform to our repo policies.
|
||||
|
||||
Plan first. Structure work into discreet tasks, according to well-defined acceptance critera. Work on a feature branch for each work item.
|
||||
|
||||
**Always check your work** and fix gaps between it and the policies and acceptance creiteria before proceeding with the next task.
|
||||
|
||||
# Formatting (do this first)
|
||||
|
||||
- [ ] If the repo has never been formatted to our standards, run `make fmt` and
|
||||
commit the result as a standalone branch/PR before any other
|
||||
changes. Formatting diffs can be large and should not be mixed with
|
||||
functional changes.
|
||||
|
||||
# Required Files
|
||||
|
||||
- [ ] `README.md` exists with all required sections (Description, Getting
|
||||
Started, Rationale, Design, TODO, License, Author)
|
||||
- [ ] `LICENSE` file exists and matches the README
|
||||
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/REPO_POLICIES.md`
|
||||
- [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) —
|
||||
fetch from `https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/.gitignore`
|
||||
if missing
|
||||
- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
|
||||
build step — fetch `.dockerignore` from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/.dockerignore`
|
||||
- [ ] Language-specific config:
|
||||
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.golangci.yml`)
|
||||
- [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
(fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierrc` and
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierignore`)
|
||||
- [ ] Python: `pyproject.toml`
|
||||
- [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above)
|
||||
|
||||
# Makefile
|
||||
|
||||
- [ ] `Makefile` exists in root — reference
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/Makefile`
|
||||
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
||||
`hooks`
|
||||
- [ ] `make check` does not modify any files in the repo
|
||||
- [ ] `make test` has a 30-second timeout
|
||||
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
|
||||
check)
|
||||
- [ ] `make check` passes on current branch
|
||||
|
||||
# Formatting
|
||||
|
||||
- [ ] Platform-standard formatter is configured (`black`, `prettier`, `go fmt`)
|
||||
- [ ] Default formatter config, only exception: four-space indents (except Go)
|
||||
- [ ] All files pass `make fmt-check`
|
||||
|
||||
# Git Hygiene
|
||||
|
||||
- [ ] Pre-commit hook is installed (`make hooks`)
|
||||
- [ ] No secrets in the repo (`.env`, keys, credentials)
|
||||
- [ ] No mutable references in Dockerfiles or scripts (tags, `@latest`) — all
|
||||
pinned by cryptographic hash with version/date comment
|
||||
- [ ] Using `yarn`, not `npm` (JS projects)
|
||||
|
||||
# Directory Structure
|
||||
|
||||
- [ ] No unnecessary files in repo root
|
||||
- [ ] Files organized into canonical subdirectories (`bin/`, `cmd/`, `docs/`,
|
||||
`internal/`, `static/`, etc.)
|
||||
- [ ] Go migrations in `internal/db/migrations/` and embedded in binary
|
||||
|
||||
# Final
|
||||
|
||||
- [ ] `make check` passes
|
||||
- [ ] `docker build` succeeds
|
||||
- [ ] Commit and merge fixes before starting your actual task
|
||||
88
LLM_DEV_PROMPTS/B_NEW_REPO_CHECKLIST.md
Normal file
88
LLM_DEV_PROMPTS/B_NEW_REPO_CHECKLIST.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: New Repo Checklist
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
Use this checklist when creating a new repository from scratch. Follow the steps
|
||||
in order. Full policies are at
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/REPO_POLICIES.md`.
|
||||
|
||||
Template files can be fetched from:
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/<path>`
|
||||
|
||||
# 1. Initialize
|
||||
|
||||
- [ ] `git init`
|
||||
- [ ] Ask the user for the license (MIT, GPL, or WTFPL)
|
||||
|
||||
# 2. First Commit (README only)
|
||||
|
||||
- [ ] Create `README.md` with all required sections:
|
||||
- [ ] **Description**: name, purpose, category, license, author
|
||||
- [ ] **Getting Started**: copy-pasteable code block
|
||||
- [ ] **Rationale**: why does this exist?
|
||||
- [ ] **Design**: how is it structured?
|
||||
- [ ] **TODO**: initial task list
|
||||
- [ ] **License**: matches chosen license
|
||||
- [ ] **Author**: [@sjdev](https://sjdev.co)
|
||||
- [ ] `git add README.md && git commit`
|
||||
|
||||
# 3. Scaffolding (feature branch)
|
||||
|
||||
- [ ] `git checkout -b initial-scaffolding`
|
||||
|
||||
## Fetch Template Files
|
||||
|
||||
- [ ] `.gitignore` — fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.gitignore`, extend for
|
||||
language-specific artifacts
|
||||
- [ ] `.editorconfig` — fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.editorconfig`
|
||||
- [ ] `Makefile` — fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/Makefile`, adapt
|
||||
targets for the project's language and tools
|
||||
- [ ] For JS/docs repos: `.prettierrc` and `.prettierignore` — fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierrc` and
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierignore`
|
||||
|
||||
## Create Project Files
|
||||
|
||||
- [ ] `LICENSE` file matching the chosen license
|
||||
- [ ] `REPO_POLICIES.md` — fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/REPO_POLICIES.md`
|
||||
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.dockerignore`
|
||||
- All Dockerfiles must run `make check` as a build step
|
||||
- Server: also builds and runs the application
|
||||
- Non-server: brings up dev environment and runs `make check`
|
||||
- Image pinned by sha256 hash with version/date comment
|
||||
- [ ] Language-specific:
|
||||
- [ ] Go: `go mod init sjdev.co/go/<name>`, `.golangci.yml`
|
||||
- [ ] JS: `npm init`, `npm i --dev prettier`
|
||||
- [ ] Python: `pyproject.toml`
|
||||
|
||||
## Configure Makefile
|
||||
|
||||
- [ ] `make test` — runs real tests, not a no-op (30-second timeout)
|
||||
- [ ] `make lint` — runs linter
|
||||
- [ ] `make fmt` — formats code (writes)
|
||||
- [ ] `make fmt-check` — checks formatting (read-only)
|
||||
- [ ] `make check` — prereqs: `test`, `lint`, `fmt-check`; must not modify files
|
||||
- [ ] `make docker` — builds Docker image
|
||||
- [ ] `make hooks` — installs pre-commit hook
|
||||
|
||||
# 4. Verify
|
||||
|
||||
- [ ] `make check` passes
|
||||
- [ ] `make docker` succeeds
|
||||
- [ ] No exposed secrets in repo
|
||||
- [ ] No mutable image/package references
|
||||
- [ ] No unnecessary files in repo root
|
||||
- [ ] All dates written as YYYY-MM-DD
|
||||
|
||||
# 5. Merge and Set Up
|
||||
|
||||
- [ ] Commit, merge to `main`
|
||||
- [ ] `make hooks` to install pre-commit hook
|
||||
- [ ] Add remote and push
|
||||
- [ ] Verify `main` passes `make check`
|
||||
76
LLM_DEV_PROMPTS/C_GENERAL_CODE_STYLEGUIDE.md
Normal file
76
LLM_DEV_PROMPTS/C_GENERAL_CODE_STYLEGUIDE.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Code Styleguide
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
# All
|
||||
|
||||
1. Every repo must have a `Makefile` and a `Dockerfile`. See
|
||||
[Repository Policies](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/REPO_POLICIES.md)
|
||||
for required targets and conventions.
|
||||
|
||||
2. Credentials and/or secrets should never be committed to any repository, even private ones. Store secrets in environment variables, and if they are absolutely required, check on startup to make sure they are set/non-default and complain loudly if not. Exception, sometimes: public keys. (Public keys can still sometimes be secrets for operational security reasons.)
|
||||
|
||||
3. KISS & DRY: Keep it simple, stupid (KISS) and Don't Repeat Yourself (DRY) to minimize complexity and technical debt.
|
||||
|
||||
4. Composition over Inheritance: Prefer combining simple objects to build complex ones rather than creating deep class hierarchies.
|
||||
|
||||
5. Avoid nesting `if` statements. If you have more than one level of nesting,
|
||||
consider inverting the condition and using `return` to exit early.
|
||||
|
||||
6. Almost all services/servers should accept their configuration via environment
|
||||
variables. Only go full config file if absolutely necessary.
|
||||
|
||||
7. For services/servers, log JSON to stdout. This makes it easier to parse and
|
||||
aggregate logs when run under `docker`. Use structured logging whenever
|
||||
possible. You may detect if the output is a terminal and pretty-print the
|
||||
logs in that case.
|
||||
|
||||
8. Debug mode is enabled by setting the environment variable `DEBUG` to a
|
||||
non-empty string. This should enable verbose logging and such. It will never
|
||||
be enabled in prod.
|
||||
|
||||
9. For services/servers, make a healthcheck available at
|
||||
`/.well-known/healthcheck`. The response must have a
|
||||
`Content-Type: application/json` header and return a JSON object containing
|
||||
the service's name, uptime, and a key of `"status"` with a value of `"ok"`.
|
||||
Return a 200 for healthy, 5xx for unhealthy.
|
||||
|
||||
10. If possible, for services/servers, include a /metrics endpoint that returns
|
||||
Prometheus-formatted metrics. This is not required for all services, but is a
|
||||
nice-to-have.
|
||||
|
||||
# Bash / Shell
|
||||
|
||||
1. Use `[[` instead of `[` for conditionals.
|
||||
|
||||
2. Use `$( )` instead of backticks.
|
||||
|
||||
3. Use `#!/usr/bin/env bash` as the shebang line. This allows the script to be
|
||||
run on systems where `bash` is not in `/bin`.
|
||||
|
||||
4. Use `set -euo pipefail` at the top of every script. This will cause the
|
||||
script to exit if any command fails, or if a variable is used before it is set.
|
||||
|
||||
5. Use `pv` for progress bars when piping data through a command.
|
||||
|
||||
6. Put all code in functions, even a main function. Define all functions then
|
||||
call main at the bottom of the file.
|
||||
|
||||
# Docker Containers (for services)
|
||||
|
||||
1. Use `runit` with `runsvinit` as the entrypoint for all containers. This
|
||||
allows for easy service management and logging. In startup scripts
|
||||
(`/etc/service/*/run`) in the container, put a `sleep 1` at the top of the
|
||||
script to avoid spiking the cpu in the case of a fast-exiting process (such
|
||||
as in an error condition). This also limits the maximum number of error
|
||||
messages in logs to 86400/day.
|
||||
|
||||
# Author
|
||||
|
||||
[@sjdev](https://sjdev.co)
|
||||
<[sj@sjdev.co(mailto:sj@sjdev.berlin)>
|
||||
|
||||
# License
|
||||
|
||||
MIT. See [LICENSE](../LICENSE).
|
||||
183
LLM_DEV_PROMPTS/D_REPO_POLICIES.md
Normal file
183
LLM_DEV_PROMPTS/D_REPO_POLICIES.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/Makefile`.
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
||||
target to install the pre-commit hook.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.golangci.yml`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sjdev that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sjdev](https://sjdev.co).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sjdev.co/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain:
|
||||
- `README.md`, `.git`, `.gitignore`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
11
LLM_DEV_PROMPTS/Dockerfile
Normal file
11
LLM_DEV_PROMPTS/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
# node 22-alpine, 2026-02-22
|
||||
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
||||
|
||||
RUN apk add --no-cache make
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
RUN make check
|
||||
575
LLM_DEV_PROMPTS/GOLANG PROJECTS/CODE_STYLEGUIDE_GO.md
Normal file
575
LLM_DEV_PROMPTS/GOLANG PROJECTS/CODE_STYLEGUIDE_GO.md
Normal file
@@ -0,0 +1,575 @@
|
||||
---
|
||||
title: Code Styleguide — Go
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
1. Hard wrap long lines at 80 characters or less.
|
||||
|
||||
2. Always `go fmt` code before committing it. The one, rare exception is
|
||||
when committing code that is not yet syntactically valid. This should
|
||||
only happen pre-v0.0.1 or on a non-`main` branch.
|
||||
|
||||
3. Even if you planning to deal with only positive integers, use
|
||||
`int`/`int64` types instead of `uint`/`uint64` types. This is for
|
||||
consistency and compatibility with the standard library.
|
||||
|
||||
4. Any project with more than 3 modules should use the `go.uber.org/fx`
|
||||
injection framework.
|
||||
|
||||
5. Embed the git commit hash into the binary and include it in startup logs and
|
||||
in health check output, to aid correlattion of running instances with their
|
||||
code. Do not include build time or build user, which will make the build
|
||||
nondeterministic.
|
||||
|
||||
Example relevant Makefile sections:
|
||||
|
||||
Given a `main.go` like:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
Version string
|
||||
Buildarch string
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Buildarch: %s\n", Buildarch)
|
||||
}
|
||||
```
|
||||
|
||||
```make
|
||||
VERSION := $(shell git describe --always --dirty)
|
||||
BUILDARCH := $(shell uname -m)
|
||||
|
||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
||||
|
||||
# osx can't statically link apparently?!
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||
endif
|
||||
|
||||
ifneq ($(UNAME_S),Darwin)
|
||||
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
|
||||
endif
|
||||
|
||||
./httpd: ./pkg/*/*.go ./internal/*/*.go cmd/httpd/*.go
|
||||
go build -o $@ $(GOFLAGS) ./cmd/httpd/*.go
|
||||
```
|
||||
6. Use `log/slog` for structured logging. Import `sjdev.co/go/simplelog`
|
||||
for sensible defaults. Example:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
_ "sjdev.co/go/simplelog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.Info("Starting up")
|
||||
}
|
||||
```
|
||||
|
||||
7. Commit at least a single test file to check compilation. The test file can
|
||||
be empty, but should exist. This ensuress that `go test ./...` will
|
||||
always function as a syntax check.
|
||||
|
||||
8. When fixing a specific bug, write a test that reproduces it, before
|
||||
fixing it. This will fix the experience of discovering the bug and the fix into
|
||||
the repo history.
|
||||
|
||||
9. For anything beyond a simple script or tool, or anything that is going to
|
||||
run in any sort of "production" anywhere, make sure it passes
|
||||
`golangci-lint`.
|
||||
|
||||
10. Write a `Dockerfile` for every repo, even if it only runs the tests and
|
||||
linting. `docker build .` should always make sure that the code is in an
|
||||
able-to-be-compiled state, linted, and any tests run. The Docker build
|
||||
should fail if linting doesn't pass.
|
||||
|
||||
11. Every repo must have a `Makefile`. See
|
||||
[Repository Policies](https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/REPO_POLICIES.md)
|
||||
for required targets and conventions.
|
||||
|
||||
12. If you are writing a single-module library, `.go` files are permissible in the repo
|
||||
root.
|
||||
|
||||
13. If you are writing a multi-module project, put all `.go` files in a `pkg/`
|
||||
or `internal/` subdirectory. `internal/` is for modules used only by the
|
||||
current repo, and `pkg/` is for modules that can be consumed externally.
|
||||
|
||||
14. Binaries go in `cmd/` directories. Each binary should have its own
|
||||
directory. This is to keep the root clean and to make it easier to distinguish a
|
||||
library from a binary. Only package `main` files should be in
|
||||
`cmd/*` directories.
|
||||
|
||||
15. Keep the `main()` function as small as possible.
|
||||
|
||||
16. Keep the `main` package as small as possible. Move as much code as is
|
||||
feasible to a library package, even if it's an internal one. `main` is
|
||||
an entrypoint to the code, not a place for implementations. Exception:
|
||||
single-file scripts.
|
||||
|
||||
17. HTTP HandleFuncs should be returned from methods or functions that need to
|
||||
handle HTTP requests. Don't use methods or your top level functions as
|
||||
handlers.
|
||||
|
||||
18. Provide a .gitignore file that ignores at least `*.log`, `*.out`, and
|
||||
`*.test` files, as well as any binaries.
|
||||
|
||||
19. Constructors should be called `New()` whenever possible. `modulename.New()`
|
||||
works great if you name the packages properly.
|
||||
|
||||
20. Don't make packages too big. Break them up.
|
||||
|
||||
21. Don't make functions or methods too big. Break them up.
|
||||
|
||||
22. Use descriptive names for functions and methods. Don't be afraid to make
|
||||
them a bit long.
|
||||
|
||||
23. Use descriptive names for modules and filenames. Avoid generic names like
|
||||
`server`. `util` is banned.
|
||||
|
||||
24. Constructors should take a Params struct if they need more than 1-2
|
||||
arguments. Positional arguments are an endless source of bugs and should be
|
||||
avoided whenever possible.
|
||||
|
||||
25. Use `context.Context` for all functions that need it. If you don't need it,
|
||||
you can pass `context.Background()`. Anything long-running should get and
|
||||
abide by a Context. A context does not count against your number of function
|
||||
or method arguments for purposes of calculating whether or not you need a
|
||||
Params struct, because the `ctx` is always first.
|
||||
|
||||
26. Contexts are always named `ctx`.
|
||||
|
||||
27. Use `context.WithTimeout` or `context.WithDeadline` for any function that
|
||||
could potentially run for a long time. This is especially true for any
|
||||
function that makes a network call. Sane timeouts are essential.
|
||||
|
||||
28. If a structure/type is only used in one function or method, define it there.
|
||||
If it's used in more than one, define it in the package. Keep it close to
|
||||
its usages. For example:
|
||||
|
||||
```go
|
||||
func (m *Mothership) tvPost() http.HandlerFunc {
|
||||
|
||||
type MSTVRequest struct {
|
||||
URL string `json:"URL"`
|
||||
}
|
||||
|
||||
type MSTVResponse struct {
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// parse json from request
|
||||
var reqParsed MSTVRequest
|
||||
err = json.NewDecoder(r.Body).Decode(&reqParsed)
|
||||
...
|
||||
|
||||
if err != nil {
|
||||
SendErrorResponse(w, MSGenericError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Msgf("Casting to %s: %s", tvName, streamURL)
|
||||
SendSuccessResponse(w, &MSTVResponse{})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
29. Avoid global state, especially global variables. If you need to store state
|
||||
that is global to your launch or application instance, use a package
|
||||
`globals` or `appstate` with a struct and a constructor and require it as a
|
||||
dependency in your constructors. This will allow consumers to be more easily
|
||||
testable and will make it easier to reason about the state of your
|
||||
application. Alternately, if your dependency graph allows for it, put it in
|
||||
the main struct/object of your application, but remember that this harms
|
||||
testability.
|
||||
|
||||
30. Package-global "variables" are ok if they are constants, such as static
|
||||
strings or integers or errors.
|
||||
|
||||
31. Whenever possible, avoid hardcoding numbers or values in your code. Use
|
||||
descriptively-named constants instead. Recall the famous SICP quote:
|
||||
"Programs must be written for people to read, and only incidentally for
|
||||
machines to execute." Rather than comments, a descriptive constant name is
|
||||
much cleaner.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
||||
w.WriteHeader(status)
|
||||
w.Header().Set("Content-Type", jsonContentType)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
32. Define your struct types near their constructors.
|
||||
|
||||
33. Do not create packages whose sole purpose is to hold type definitions.
|
||||
Packages named `types`, `domain`, or `models` that contain only structs and
|
||||
interfaces (with no behavior) are a code smell. Define types alongside the
|
||||
code that uses them. Type-only packages force consuming packages into alias
|
||||
imports and circular-dependency gymnastics, and indicate that the package
|
||||
boundaries were drawn around nouns instead of responsibilities. If multiple
|
||||
packages need the same type, put it in the package that owns the behavior,
|
||||
or in a small, focused interface package — not in a grab-bag types package.
|
||||
|
||||
34. When defining custom string-based types (e.g. `type ImageID string`),
|
||||
implement `fmt.Stringer`. Use `.String()` at SDK and library boundaries
|
||||
instead of `string(v)`. This makes type conversions explicit, grep-able, and
|
||||
consistent across the codebase. Example:
|
||||
|
||||
```go
|
||||
type ContainerID string
|
||||
|
||||
func (id ContainerID) String() string { return string(id) }
|
||||
|
||||
// At the Docker SDK boundary:
|
||||
resp, err := c.docker.ContainerStart(ctx, id.String(), opts)
|
||||
```
|
||||
|
||||
35. Define your interface types near the functions that use them, or if you have
|
||||
multiple conformant types, put the interface(s) in their own file.
|
||||
|
||||
36. Define errors as package-level variables. Use a descriptive name for the
|
||||
error. Use `errors.New` to create the error. If you need to include
|
||||
additional information in the error, use a struct that implements the
|
||||
`error` interface.
|
||||
|
||||
37. Use lowerCamelCase for local function/variable names. Use UpperCamelCase for
|
||||
type names, and exported function/variable names. Use snake_case for JSON
|
||||
keys. Use lowercase for filenames.
|
||||
|
||||
38. Explicitly specify UTC for datetimes unless you have a very good reason not
|
||||
to. Use `time.Now().UTC()` to get the current time in UTC.
|
||||
|
||||
39. String dates should always be ISO8601 formatted. Use `time.Time.Format` with
|
||||
`time.RFC3339` to get the correct format.
|
||||
|
||||
40. Use `time.Time` for all date and time values. Do not use `int64` or `string`
|
||||
for dates or times internally.
|
||||
|
||||
41. When using `time.Time` in a struct, use a pointer to `time.Time` so that you
|
||||
can differentiate between a zero value and a null value.
|
||||
|
||||
42. Use `time.Duration` for all time durations. Do not use `int64` or `string`
|
||||
for durations internally.
|
||||
|
||||
43. When using `time.Duration` in a struct, use a pointer to `time.Duration` so
|
||||
that you can differentiate between a zero value and a null value.
|
||||
|
||||
44. Whenever possible, in argument types and return types, try to use standard
|
||||
library interfaces instead of concrete types. For example, use `io.Reader`
|
||||
instead of `*os.File`. Tailor these to the needs of the specific function or
|
||||
method. Examples:
|
||||
- **`io.Reader`** instead of `*os.File`:
|
||||
- `io.Reader` is a common interface for reading data, which can be
|
||||
implemented by many types, including `*os.File`, `bytes.Buffer`,
|
||||
`strings.Reader`, and network connections like `net.Conn`.
|
||||
|
||||
- **`io.Writer`** instead of `*os.File` or `*bytes.Buffer`:
|
||||
- `io.Writer` is used for writing data. It can be implemented by
|
||||
`*os.File`, `bytes.Buffer`, `net.Conn`, and more.
|
||||
|
||||
- **`io.ReadWriter`** instead of `*os.File`:
|
||||
- `io.ReadWriter` combines `io.Reader` and `io.Writer`. It is often used
|
||||
for types that can both read and write, such as `*os.File` and
|
||||
`net.Conn`.
|
||||
|
||||
- **`io.Closer`** instead of `*os.File` or `*net.Conn`:
|
||||
- `io.Closer` is used for types that need to be closed, including
|
||||
`*os.File`, `net.Conn`, and other resources that require cleanup.
|
||||
|
||||
- **`io.ReadCloser`** instead of `*os.File` or `http.Response.Body`:
|
||||
- `io.ReadCloser` combines `io.Reader` and `io.Closer`, and is commonly
|
||||
used for types like `*os.File` and `http.Response.Body`.
|
||||
|
||||
- **`io.WriteCloser`** instead of `*os.File` or `*gzip.Writer`:
|
||||
- `io.WriteCloser` combines `io.Writer` and `io.Closer`. It is used for
|
||||
types like `*os.File` and `gzip.Writer`.
|
||||
|
||||
- **`io.ReadWriteCloser`** instead of `*os.File` or `*net.TCPConn`:
|
||||
- `io.ReadWriteCloser` combines `io.Reader`, `io.Writer`, and
|
||||
`io.Closer`. Examples include `*os.File` and `net.TCPConn`.
|
||||
|
||||
- **`fmt.Stringer`** instead of implementing a custom `String` method:
|
||||
- `fmt.Stringer` is an interface for types that can convert themselves
|
||||
to a string. Any type that implements the `String() string` method
|
||||
satisfies this interface.
|
||||
|
||||
- **`error`** instead of custom error types:
|
||||
- The `error` interface is used for representing errors. Instead of
|
||||
defining custom error types, you can use the `errors.New` function or
|
||||
the `fmt.Errorf` function to create errors.
|
||||
|
||||
- **`net.Conn`** instead of `*net.TCPConn` or `*net.UDPConn`:
|
||||
- `net.Conn` is a generic network connection interface that can be
|
||||
implemented by TCP, UDP, and other types of network connections.
|
||||
|
||||
- **`http.Handler`** instead of custom HTTP handlers:
|
||||
- `http.Handler` is an interface for handling HTTP requests. Instead of
|
||||
creating custom handler types, you can use types that implement the
|
||||
`ServeHTTP(http.ResponseWriter, *http.Request)` method.
|
||||
|
||||
- **`http.HandlerFunc`** instead of creating a new type:
|
||||
- `http.HandlerFunc` is a type that allows you to use functions as HTTP
|
||||
handlers by implementing the `http.Handler` interface.
|
||||
|
||||
- **`encoding.BinaryMarshaler` and `encoding.BinaryUnmarshaler`** instead of
|
||||
custom marshal/unmarshal methods:
|
||||
- These interfaces are used for binary serialization and
|
||||
deserialization. Implementing these interfaces allows types to be
|
||||
encoded and decoded in a standard way.
|
||||
|
||||
- **`encoding.TextMarshaler` and `encoding.TextUnmarshaler`** instead of
|
||||
custom text marshal/unmarshal methods:
|
||||
- These interfaces are used for text-based serialization and
|
||||
deserialization. They are useful for types that need to be represented
|
||||
as text.
|
||||
|
||||
- **`sort.Interface`** instead of custom sorting logic:
|
||||
- `sort.Interface` is an interface for sorting collections. By
|
||||
implementing the `Len`, `Less`, and `Swap` methods, you can sort any
|
||||
collection using the `sort.Sort` function.
|
||||
|
||||
- **`flag.Value`** instead of custom flag parsing:
|
||||
- `flag.Value` is an interface for defining custom command-line flags.
|
||||
Implementing the `String` and `Set` methods allows you to use custom
|
||||
types with the `flag` package.
|
||||
|
||||
45. Avoid using `panic` in library code. Instead, return errors to allow the
|
||||
caller to handle them. Reserve `panic` for truly exceptional conditions.
|
||||
|
||||
46. Use `defer` to ensure resources are properly cleaned up, such as closing
|
||||
files or network connections. Place `defer` statements immediately after
|
||||
resource acquisition.
|
||||
|
||||
47. When calling a function with `go`, wrap it in an anonymous function to ensure
|
||||
it runs in the new goroutine context:
|
||||
|
||||
Right:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
someFunction(arg1, arg2)
|
||||
}()
|
||||
```
|
||||
|
||||
Wrong:
|
||||
|
||||
```go
|
||||
go someFunction(arg1, arg2)
|
||||
```
|
||||
|
||||
48. Use `iota` to define enumerations in a type-safe way. This ensures that the
|
||||
constants are properly grouped and reduces the risk of errors.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
|
||||
type HandScore int
|
||||
|
||||
const (
|
||||
ScoreHighCard = HandScore(iota * 100_000_000_000)
|
||||
ScorePair
|
||||
ScoreTwoPair
|
||||
ScoreThreeOfAKind
|
||||
ScoreStraight
|
||||
ScoreFlush
|
||||
ScoreFullHouse
|
||||
ScoreFourOfAKind
|
||||
ScoreStraightFlush
|
||||
ScoreRoyalFlush
|
||||
)
|
||||
```
|
||||
|
||||
Example 2:
|
||||
|
||||
```go
|
||||
type ByteSize float64
|
||||
|
||||
const (
|
||||
_ = iota // ignore first value by assigning to blank identifier
|
||||
KB ByteSize = 1 << (10 * iota)
|
||||
MB
|
||||
GB
|
||||
TB
|
||||
PB
|
||||
EB
|
||||
ZB
|
||||
YB
|
||||
)
|
||||
```
|
||||
|
||||
49. Do not hardcode large lists. Either isolate lists
|
||||
in their own module/package and write getters, or use a third party
|
||||
library. For example, if you need a list of country codes, you can use
|
||||
[https://github.com/emvi/iso-639-1](https://github.com/emvi/iso-639-1). It is
|
||||
permissible to embed a data file (use `go embed`) in your binary,
|
||||
but make sure you parse it once as a singleton and don't read it from disk
|
||||
every time you need it. Don't use too much memory for this, embedding
|
||||
anything more than perhaps 25MiB (uncompressed) is probably too much.
|
||||
Compress the file before embedding and uncompress during the reading/parsing
|
||||
step.
|
||||
|
||||
50. When storing numeric values that represent a number of units, either include
|
||||
the unit in the variable name (e.g. `uptimeSeconds`, `delayMsec`,
|
||||
`coreTemperatureCelsius`), or use a type alias (that includes the unit
|
||||
name), or use a 3p library such as
|
||||
[github.com/alecthomas/units](https://github.com/alecthomas/units) for
|
||||
SI/IEC byte units, or
|
||||
[github.com/bcicen/go-units](https://github.com/bcicen/go-units) for
|
||||
temperatures (and others). The type system is your friend, use it.
|
||||
|
||||
51. Once you have a working program, run `go mod tidy` to clean up your `go.mod`
|
||||
and `go.sum` files. Tag a v0.0.1 or v1.0.0. Push your `main` branch and
|
||||
tag(s). Subsequent work should happen on branches so that `main` is "always
|
||||
releasable". "Releasable" in this context means that it builds and functions
|
||||
as expected, and that all tests and linting passes.
|
||||
|
||||
# Other Golang Best Practices (Optional)
|
||||
|
||||
1. For any internet-facing http server, set appropriate timeouts and limits to
|
||||
protect against slowloris attacks or huge uploads that can consume server
|
||||
resources without authentication.
|
||||
|
||||
Example to limit request body size:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Limit the request body to 10MB
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Hello, World!")
|
||||
})
|
||||
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
```
|
||||
|
||||
Example to set appropriate timeouts:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := &http.Server{
|
||||
Addr: ":8080",
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
Handler: http.DefaultServeMux,
|
||||
}
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "Hello, World!")
|
||||
})
|
||||
|
||||
server.ListenAndServe()
|
||||
}
|
||||
```
|
||||
|
||||
2. When passing channels to goroutines, use read-only (`<-chan`) or write-only
|
||||
(`chan<-`) channels to communicate the direction of data flow clearly.
|
||||
|
||||
3. Use `io.MultiReader` to concatenate multiple readers and `io.MultiWriter` to
|
||||
duplicate writes to multiple writers. This can simplify the handling of
|
||||
multiple data sources or destinations.
|
||||
|
||||
4. For simple counters and flags, use the `sync/atomic` package to avoid the
|
||||
overhead of mutexes.
|
||||
|
||||
5. When using mutexes, minimize the scope of locking to reduce contention and
|
||||
potential deadlocks. Prefer to lock only the critical sections of code and try
|
||||
to encapsulate it in its own method. Acquire
|
||||
the lock in the first function line, defer release of the lock as the
|
||||
second line, and lines 3-5 should perform the task. Keep it short. Avoid using mutexes in the middle of a function. In short, build atomic functions.
|
||||
|
||||
6. Design types to be immutable, to avoid issues with concurrent access.
|
||||
|
||||
7. Global state can lead to unpredictable behavior and makes the code harder to
|
||||
test. Use dependency injection to manage state.
|
||||
|
||||
8. Avoid using `init` functions unless absolutely necessary. Tthey can lead to
|
||||
unpredictable initialization order and make code harder to understand.
|
||||
|
||||
9. Provide comments for all public interfaces explaining what they do and how
|
||||
they should be used, to help other developers understand the intended use.
|
||||
|
||||
10. Be mindful of resource leaks when using `time.Timer` and `time.Ticker`.
|
||||
Always stop them when they are no longer needed.
|
||||
|
||||
11. Use `sync.Pool` to manage a pool of reusable objects, which can help reduce
|
||||
GC overhead and improve performance in high-throughput scenarios.
|
||||
|
||||
12. Avoid using large buffer sizes for channels. Unbounded channels can lead to
|
||||
memory leaks. Use appropriate buffer sizes based on the application's needs.
|
||||
|
||||
13. Always handle the case where a channel might be closed. This prevents panic
|
||||
and ensures graceful shutdowns.
|
||||
|
||||
14. For small structs, use value receivers to avoid unnecessary heap allocations.
|
||||
Use pointer receivers for large structs or when mutating the receiver.
|
||||
|
||||
15. Only use goroutines when necessary. Excessive goroutines can lead to high
|
||||
memory consumption and increased complexity.
|
||||
|
||||
16. Use `sync.Cond` for more complex synchronization needs that cannot be met
|
||||
with simple mutexes and channels.
|
||||
|
||||
17. Reflection is powerful but should be used sparingly as it can lead to code
|
||||
that is hard to understand and maintain. Prefer type-safe solutions.
|
||||
|
||||
18. Avoid storing large or complex data in context. Context should be used for
|
||||
request-scoped values like deadlines, cancellation signals, and
|
||||
authentication tokens.
|
||||
|
||||
19. Use `runtime.Callers` and `runtime.CallersFrames` to capture stack traces for
|
||||
debugging and logging purposes.
|
||||
|
||||
20. Use the `testing.TB` interface to write helper functions that can be used
|
||||
with both `*testing.T` and `*testing.B`.
|
||||
|
||||
21. Use struct embedding to reuse code across multiple structs. This form of
|
||||
composition simplifies code reuse.
|
||||
|
||||
22. Prefer defining explicit interfaces in your packages rather than relying on
|
||||
implicit interfaces, for clarity.
|
||||
|
||||
# Author
|
||||
|
||||
[@sjdev](https://sjdev.co)
|
||||
<[sj@sjdev.co(mailto:sj@sjdev.berlin)>
|
||||
|
||||
# License
|
||||
|
||||
MIT. See [LICENSE](../LICENSE).
|
||||
1242
LLM_DEV_PROMPTS/GOLANG PROJECTS/GO_HTTP_SERVER_CONVENTIONS.md
Normal file
1242
LLM_DEV_PROMPTS/GOLANG PROJECTS/GO_HTTP_SERVER_CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
21
LLM_DEV_PROMPTS/LICENSE
Normal file
21
LLM_DEV_PROMPTS/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 sjdev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
31
LLM_DEV_PROMPTS/Makefile
Normal file
31
LLM_DEV_PROMPTS/Makefile
Normal file
@@ -0,0 +1,31 @@
|
||||
.PHONY: test lint fmt fmt-check check docker hooks
|
||||
|
||||
# flags are repeated here (also in .prettierrc) so this Makefile works
|
||||
# standalone when copied as a template
|
||||
PRETTIER := yarn run prettier
|
||||
|
||||
test:
|
||||
@echo "No tests defined."
|
||||
|
||||
lint:
|
||||
@echo "Linting markdown files..."
|
||||
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||
|
||||
fmt:
|
||||
@$(PRETTIER) --write '**/*.md' --tab-width 4 --prose-wrap always
|
||||
|
||||
fmt-check:
|
||||
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||
|
||||
check: test lint fmt-check
|
||||
|
||||
docker:
|
||||
docker build -t prompts .
|
||||
|
||||
hooks:
|
||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||
@if [ -f go.mod ]; then \
|
||||
printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit; \
|
||||
fi
|
||||
@printf 'make check\n' >> .git/hooks/pre-commit
|
||||
@chmod +x .git/hooks/pre-commit
|
||||
81
LLM_DEV_PROMPTS/NODEJS PROJECTS/CODE_STYLEGUIDE_JS.md
Normal file
81
LLM_DEV_PROMPTS/NODEJS PROJECTS/CODE_STYLEGUIDE_JS.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Code Styleguide — JavaScript
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
1. Use `const` for all declarations, unless you need to reassign, then use `let`. Never use
|
||||
`var`.
|
||||
|
||||
2. Indentation: Use 2 spaces. Do not use tabs.
|
||||
|
||||
3. Semicolons shoudl be used at the end of statements.
|
||||
|
||||
4. Quotes: Prefer single quotes for strings, unless working with JSON or needing to separate object strings.
|
||||
|
||||
5. Limit lines to approximately 80 characters for better readability.
|
||||
|
||||
6. Brace Placement: Place the opening brace on the same line as the statement (e.g., if (true) {).
|
||||
|
||||
7. Naming Conventions:
|
||||
|
||||
Variables, properties, and functions use lowerCamelCase.
|
||||
Class names use UpperCamelCase (PascalCase).
|
||||
Constants use UPPERCASE_WITH_UNDERSCORES.
|
||||
|
||||
8. Equality: Always use the strict equality operator (===) over the abstract equality operator (==).
|
||||
|
||||
9. Use npm for package management, avoid using yarn.
|
||||
|
||||
10. Use nvm and install/select the most current LTS Node version.
|
||||
|
||||
11. Use `prettier` for code formatting, with four spaces for indentation.
|
||||
|
||||
12. At a minimum, both `npm run test`/`npm run build` should work (complete the appropriate scripts in `package.json`). However, prefer `make test` and `make build` instead —
|
||||
|
||||
13. The Makefile is authoritative on how to interact with the repo. See
|
||||
[Repository Policies](https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/REPO_POLICIES.md) for details.
|
||||
|
||||
14. Use UNIX-style newlines (\n), and a newline character as the last character of a file. Windows-style newlines (\r\n) are forbidden inside any Node/JS repository.
|
||||
|
||||
15. Declare one variable per statement:
|
||||
|
||||
**Correct:**
|
||||
|
||||
```
|
||||
const keys = ['foo', 'bar'];
|
||||
const values = [23, 42];
|
||||
const object = {};
|
||||
```
|
||||
**Incorrect:**
|
||||
|
||||
```
|
||||
const keys = ['foo', 'bar'],
|
||||
values = [23, 42],
|
||||
object = {},
|
||||
```
|
||||
|
||||
16. Name closures in order to produce better stack traces, heap and cpu profiles.
|
||||
|
||||
**Correct:**
|
||||
|
||||
```
|
||||
req.on('end', function onEnd() {
|
||||
console.log('winning');
|
||||
});
|
||||
```
|
||||
**Incorrect:**
|
||||
|
||||
```
|
||||
req.on('end', function() {
|
||||
console.log('losing');
|
||||
});
|
||||
```
|
||||
|
||||
# Author
|
||||
|
||||
[@sjDev](https://sjdev.co)
|
||||
<[sj@sjdev.co](mailto:sj@sjdev.co)>
|
||||
|
||||
# License
|
||||
|
||||
MIT. See [LICENSE](../LICENSE).
|
||||
30
LLM_DEV_PROMPTS/NODEJS PROJECTS/NODE_BACKEND_ARCH_BASICS.md
Normal file
30
LLM_DEV_PROMPTS/NODEJS PROJECTS/NODE_BACKEND_ARCH_BASICS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Node Backend Architectural Basic Principals
|
||||
last_modified: 2026-03-02
|
||||
---
|
||||
|
||||
1. Separate concerns into distinct layers.
|
||||
|
||||
2. Adhering to the above pricinciple makes c ode more extensible, testable and maintainable. Group around functionality, adhering to the following structure on the backend:
|
||||
|
||||
a. **Routes/Controllers:** Handle API endpoints and process the request/response cycle. Controllers should be kept lean, delegating business logic to the service layer.
|
||||
|
||||
b. **Services/Business Logic:** Contain the core application logic and domain rules. This layer orchestrates interactions between other components.
|
||||
|
||||
c. **Models/Data Access:** Interact with the database (using ORMs like Mongoose or Sequelize). Abstract database logic into repositories for reusability.
|
||||
|
||||
d. **Middleware:** Used for cross-cutting concerns such as cors, authentication, logging, and error handling.
|
||||
|
||||
3. Modularity: Break code into the smallest reusable modules that make logical sense. Each discreet class or method should have a single responsibility.
|
||||
|
||||
4. Objects should depend on abstractions, not concretions. High-level modules contain the core business logic or application-specific behavior.
|
||||
Lower-level modules deal with implementation details, such as interacting with a database, file system, or external APIs.
|
||||
|
||||
# Author
|
||||
|
||||
[@sjDev](https://sjdev.co)
|
||||
<[sj@sjdev.co](mailto:sj@sjdev.co)>
|
||||
|
||||
# License
|
||||
|
||||
MIT. See [LICENSE](../LICENSE).
|
||||
54
LLM_DEV_PROMPTS/PYTHON PROJECTS/CODE_STYLEGUIDE_PYTHON.md
Normal file
54
LLM_DEV_PROMPTS/PYTHON PROJECTS/CODE_STYLEGUIDE_PYTHON.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Code Styleguide — Python
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
1. Standard Project Layout. Use the src/ layout to prevent accidental imports from the root and ensure your project behaves like an installed package:
|
||||
|
||||
my_project/
|
||||
├── pyproject.toml # Modern tool & dependency configuration
|
||||
├── README.md # Instructions for humans
|
||||
├── LICENSE # Usage rights
|
||||
├── .gitignore # Exclude venv/, __pycache__/, .env
|
||||
├── src/
|
||||
│ └── my_project/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # Minimal entry point logic
|
||||
│ └── core.py # Business logic
|
||||
├── tests/ # Mirror src structure for testing
|
||||
└── docs/ # Technical documentation
|
||||
|
||||
2. Virtual Environments: Always isolate project dependencies using venv or pyenv to avoid conflicts with system-wide packages.
|
||||
|
||||
3. Adhere to PEP 8: Use 4 spaces for indentation (no tabs), limit lines to 79 characters, and use two blank lines between top-level functions.
|
||||
|
||||
4. Put code in functions. If you are writing a script, put the script in a
|
||||
function called `main` and call `main()` at the end of the script using the
|
||||
standard invocation:
|
||||
|
||||
```python
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
5. Keep main.py Boring: The entry point should only orchestrate (load config, start app). Heavy business logic should live in specialized modules.
|
||||
|
||||
6. Config Isolation: Never hardcode secrets or database URLs. Use a .env file with libraries like python-dotenv or Pydantic Settings.
|
||||
|
||||
7. Logging over Printing: Use Python’s built-in logging module to track errors and application state in production.
|
||||
|
||||
8. Test-First: Treat your tests/ directory as first-class code. Use pytest for its powerful features and simple syntax.
|
||||
|
||||
9. Naming Conventions: Use snake_case for functions and variables, PascalCase for classes, and UPPER_CASE for constants.
|
||||
|
||||
10. pyproject.toml: Use this as the single source of truth for project metadata and tool configurations (replaces setup.py).
|
||||
|
||||
11. Modular Design: Group code by domain (e.g., /users, /payments) rather than function (e.g., utils.py) to maintain separation of concerns.
|
||||
|
||||
# Author
|
||||
|
||||
[@sjdev](https://sjdev.co)
|
||||
<[sj@sjdev.co](mailto:sj@sjdev.berlin)>
|
||||
|
||||
# License
|
||||
|
||||
MIT. See [LICENSE](../LICENSE).
|
||||
139
LLM_DEV_PROMPTS/README.md
Normal file
139
LLM_DEV_PROMPTS/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# LLM Development Prompts
|
||||
|
||||
An MIT-licensed collection of LLM prompts by [@sjdev](https://sjdev.co), intended for use in bootstrapping new projects or building out new features in existing codebases.
|
||||
|
||||
The prompts set forth best practices and procedural directives that must be followed in architecture and development. Think of this repo as akin to the Chicago Manual of Style, AP Stylebook (for journalism), or The ALWD Guide to Legal Citation.
|
||||
|
||||
The prompts include 1) general repository development standards and 2) language and framework-specific development directives, intended to be reviewed by the code-building model at the outset work and followed throughout the work’s progress. For example, the Javascript code styleguide sets forth the simple directive: “[u]se const for all declarations, unless you need to reassign, then use let. Never use var.”
|
||||
|
||||
These prompts are a work in progress. I add to them as I work on new projects in new languages and frameworks, and I am also still in the process of memorializing prompts relating to languages and frameworks I have used for many years.
|
||||
|
||||
# Usage - generally
|
||||
|
||||
Imagine the scenario: you, as a developer, are tasked with adding a new feature to an existing codebase, with maximum automation via LLM code generation. At the outset, before adressing the substantive implementation, you would run the following prompt (discussed in greater detail below):
|
||||
|
||||
Read $TD/prompts/REPO_POLICIES.md and $TD/prompts/EXISTING_REPO_CHECKLIST.md, then bring this repo up to those
|
||||
standards. Your scope is repo scaffolding and policy compliance: Makefile, Dockerfile, .dockerignore, .gitignore, .editorconfig, CI
|
||||
workflow, README sections, LICENSE, REPO_POLICIES.md, and any language-specific config files (.golangci.yml, .prettierrc, etc.).
|
||||
|
||||
## Quick Start - optional scripts for cli agent
|
||||
|
||||
### Existing Repo
|
||||
|
||||
Run from within the repo you want to bring up to standard. Clone the prompts repo once, then run both commands in order.
|
||||
|
||||
```bash
|
||||
export TD="$(mktemp -d)"
|
||||
git clone --depth 1 https://github.com/kjannette/LLM_DEV_PROMPTS.git "$TD"
|
||||
```
|
||||
|
||||
**Repository structure and policies:**
|
||||
|
||||
```bash
|
||||
claude "Read $TD/prompts/REPO_POLICIES.md and
|
||||
$TD/prompts/EXISTING_REPO_CHECKLIST.md, then bring this repo up to those
|
||||
standards. Your scope is repo scaffolding and policy compliance:
|
||||
Makefile, Dockerfile, .dockerignore, .gitignore, .editorconfig, CI
|
||||
workflow, README sections, LICENSE, REPO_POLICIES.md, and any
|
||||
language-specific config files (.golangci.yml, .prettierrc, etc.).
|
||||
You must also run the formatter (make fmt) and fix any linter errors
|
||||
(make lint) so that make check passes — this will touch source code,
|
||||
but do not restructure, refactor, or rewrite any application logic.
|
||||
Follow the policies yourself: work on a feature branch, never git add -A,
|
||||
and make each logical change a separate commit (e.g. one commit for
|
||||
formatting, one for linter fixes, one for README updates, one for each
|
||||
new repo file added, etc.)."
|
||||
```
|
||||
|
||||
**Code style and conventions:**
|
||||
|
||||
```bash
|
||||
claude "Read $TD/prompts/CODE_STYLEGUIDE.md and whichever
|
||||
language-specific styleguides in $TD/prompts/ apply to this repo
|
||||
(CODE_STYLEGUIDE_GO.md, CODE_STYLEGUIDE_JS.md, CODE_STYLEGUIDE_PYTHON.md,
|
||||
GO_HTTP_SERVER_CONVENTIONS.md). Then review the application code in this
|
||||
repo and bring it into compliance with those coding standards. Your scope
|
||||
is application code structure and style: naming, patterns, error
|
||||
handling, project layout, and conventions described in the styleguides.
|
||||
Do not modify repo scaffolding (Makefile, Dockerfile, CI workflow,
|
||||
.gitignore, .editorconfig, etc.) — only application code. Work on a
|
||||
feature branch, never git add -A, and make each logical change a
|
||||
separate commit."
|
||||
```
|
||||
|
||||
### New Repo
|
||||
|
||||
Run from inside the directory where you want to create a new repo. Clone the
|
||||
prompts repo once, then run both commands in order.
|
||||
|
||||
```bash
|
||||
export TD="$(mktemp -d)"
|
||||
git clone --depth 1 https://github.com/kjannette/LLM_DEV_PROMPTS.git "$TD"
|
||||
```
|
||||
|
||||
**Repository scaffolding:**
|
||||
|
||||
```bash
|
||||
claude "Read $TD/prompts/REPO_POLICIES.md and
|
||||
$TD/prompts/NEW_REPO_CHECKLIST.md, then set up this new repo according
|
||||
to those standards. Your scope is repo structure and required files:
|
||||
README.md, LICENSE, REPO_POLICIES.md, Makefile, Dockerfile, .dockerignore,
|
||||
.gitignore, .editorconfig, CI workflow, and language-specific config.
|
||||
Run the formatter (make fmt) and fix any linter errors (make lint) so
|
||||
that make check passes — this will touch source code, but do not
|
||||
restructure, refactor, or rewrite any application logic. Follow the
|
||||
policies yourself: work on a feature branch, never git add -A, and make
|
||||
each logical change a separate commit (e.g. one commit for formatting,
|
||||
one for linter fixes, one for README, one for each new repo file, etc.)."
|
||||
```
|
||||
|
||||
**Code style and conventions:**
|
||||
|
||||
```bash
|
||||
claude "Read $TD/prompts/CODE_STYLEGUIDE.md and whichever
|
||||
language-specific styleguides in $TD/prompts/ apply to this repo
|
||||
(CODE_STYLEGUIDE_GO.md, CODE_STYLEGUIDE_JS.md, CODE_STYLEGUIDE_PYTHON.md,
|
||||
GO_HTTP_SERVER_CONVENTIONS.md). Then review the application code in this
|
||||
repo and bring it into compliance with those coding standards. Your scope
|
||||
is application code structure and style: naming, patterns, error
|
||||
handling, project layout, and conventions described in the styleguides.
|
||||
Do not modify repo scaffolding (Makefile, Dockerfile, CI workflow,
|
||||
.gitignore, .editorconfig, etc.) — only application code. Work on a
|
||||
feature branch, never git add -A, and make each logical change a
|
||||
separate commit."
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kjannette/LLM_DEV_PROMPTS.git
|
||||
cd prompts
|
||||
```
|
||||
|
||||
Prompts are stored as Markdown files in `prompts/`. Copy or reference them as
|
||||
needed in your projects.
|
||||
|
||||
## Rationale
|
||||
|
||||
LLM prompts, especially development policies, benefit from version control and a
|
||||
single authoritative source. This repo provides a central place to maintain,
|
||||
share, and evolve prompts across projects.
|
||||
|
||||
## Design
|
||||
|
||||
The repository is a collection of Markdown files organized in the `prompts/`
|
||||
subdirectory. Each file contains one or more related prompts or policy
|
||||
documents. There is no build step or runtime component; the prompts are consumed
|
||||
by copying them into other projects or referencing them directly.
|
||||
|
||||
## TODO
|
||||
|
||||
- Add more prompt templates for common development tasks
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
## Author
|
||||
|
||||
[@sjdev](https://sjdev.co)
|
||||
5
LLM_DEV_PROMPTS/package.json
Normal file
5
LLM_DEV_PROMPTS/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
}
|
||||
42
Makefile
Normal file
42
Makefile
Normal file
@@ -0,0 +1,42 @@
|
||||
.PHONY: test lint fmt fmt-check check docker hooks dev run build clean
|
||||
|
||||
SERVER_DIR := server
|
||||
WEB_DIR := client
|
||||
TIMEOUT := 30
|
||||
|
||||
test:
|
||||
cd $(SERVER_DIR) && npx --no-install vitest run --reporter verbose --testTimeout $(TIMEOUT)000
|
||||
|
||||
lint:
|
||||
cd $(SERVER_DIR) && npx --no-install eslint src/
|
||||
cd $(WEB_DIR) && npx --no-install eslint src/
|
||||
|
||||
fmt:
|
||||
npx --no-install prettier --write .
|
||||
|
||||
fmt-check:
|
||||
npx --no-install prettier --check .
|
||||
|
||||
check: test lint fmt-check
|
||||
|
||||
docker:
|
||||
docker build -t notebook-clone .
|
||||
|
||||
hooks:
|
||||
printf '#!/usr/bin/env bash\nset -euo pipefail\nmake check\n' > .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
|
||||
dev:
|
||||
@echo "Starting backend and frontend..."
|
||||
cd $(SERVER_DIR) && node src/index.js &
|
||||
cd $(WEB_DIR) && npx --no-install vite --port 5173 &
|
||||
wait
|
||||
|
||||
run:
|
||||
cd $(SERVER_DIR) && node src/index.js
|
||||
|
||||
build:
|
||||
cd $(WEB_DIR) && npx --no-install vite build
|
||||
|
||||
clean:
|
||||
rm -rf $(WEB_DIR)/dist $(SERVER_DIR)/dist
|
||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Citation Sentinel
|
||||
|
||||
A source-grounded research assistant inspired by Google's NotebookLM. MIT
|
||||
license, by [@sjdev](https://sjdev.co). Users upload documents, links, youtube videos,
|
||||
ask questions and receive answers (with inline citations) grounded in their sources.
|
||||
|
||||
Built with a React/Vite frontend and a Node.js/Express backend, using Anthropic Claude for
|
||||
generation, OpenAI Whisper for video audio track transcription, Voyage AI for embeddings and response cosine similarity scoring ("groundedness" score).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** (v18+)
|
||||
- **yt-dlp** -- required for YouTube video source support (`brew install yt-dlp` or `pip install yt-dlp`)
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# clone and install
|
||||
git clone <repo-url> && cd notebooklm_clone
|
||||
cd server && npm install && cd ..
|
||||
cd client && npm install && cd ..
|
||||
|
||||
# configure
|
||||
cp server/.env.example server/.env
|
||||
# edit server/.env and add your ANTHROPIC_API_KEY, VOYAGE_API_KEY,
|
||||
# and optionally OPENAI_API_KEY (only needed for audio source transcription via Whisper)
|
||||
|
||||
# run
|
||||
make dev
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
NotebookLM is a powerful research tool, but it is proprietary and closed.
|
||||
This clone demonstrates the core source-grounded Q&A pattern with transparent
|
||||
retrieval, generation, and groundedness scoring (LLM response quality cosine scoring) -- all with swappable models/fully open source.
|
||||
|
||||
## Design
|
||||
|
||||
Two-package monorepo:
|
||||
|
||||
- `server/` -- single Express backend with layered architecture
|
||||
(routes -> services -> stores). Routes orchestrate; services contain
|
||||
business logic; stores manage in-memory state.
|
||||
|
||||
- `client/` -- React 19 SPA via Vite. Two-panel layout: sidebar for
|
||||
notebooks/data sources, main area for LLM chat with explorable citations,
|
||||
groundedness badges (cosine similarity scoring of LLM responses), and follow-up question chips.
|
||||
|
||||
## Query pipeline
|
||||
|
||||
Embed query (Voyage) -> k-NN search -> rerank (Voyage) ->
|
||||
generate answer with citations (Claude) -> compute groundedness score
|
||||
(cosine similarity of answer vs cited chunks).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
|
||||
## Author
|
||||
|
||||
[@sjdev](https://sjdev.co)
|
||||
26
client/eslint.config.js
Normal file
26
client/eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import react from 'eslint-plugin-react';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{js,jsx}'],
|
||||
plugins: { react },
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'react/jsx-uses-vars': 'error',
|
||||
'react/jsx-uses-react': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='50' fill='black'/></svg>">
|
||||
<title>NotebookLM Clone</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6080
client/package-lock.json
generated
Normal file
6080
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
client/package.json
Normal file
32
client/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "notebook-clone-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": { "node": ">=18" },
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src/",
|
||||
"fmt": "prettier --write .",
|
||||
"fmt:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
111
client/src/App.jsx
Normal file
111
client/src/App.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import NotebookList from './components/NotebookList.jsx';
|
||||
import SourcePanel from './components/SourcePanel.jsx';
|
||||
import DocumentButtons from './components/DocumentButtons.jsx';
|
||||
import ChatPanel from './components/ChatPanel.jsx';
|
||||
import DocumentModal from './components/DocumentModal.jsx';
|
||||
import { useNotebook } from './hooks/useNotebook.js';
|
||||
import { generateDocument } from './api/documents.js';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
notebooks,
|
||||
activeNotebook,
|
||||
selectNotebook,
|
||||
createNotebook,
|
||||
deleteNotebook,
|
||||
} = useNotebook();
|
||||
|
||||
const [hoverState, setHoverState] = useState(null);
|
||||
const [sourceCount, setSourceCount] = useState(0);
|
||||
const [generatingType, setGeneratingType] = useState(null);
|
||||
const [docModal, setDocModal] = useState({ open: false, type: null, document: null, loading: false });
|
||||
const [chatReady, setChatReady] = useState(false);
|
||||
|
||||
const handleSelectNotebook = (id) => {
|
||||
setChatReady(false);
|
||||
selectNotebook(id);
|
||||
};
|
||||
|
||||
const handleSourceHover = (val) => {
|
||||
if (val === null) {
|
||||
setHoverState(null);
|
||||
} else if (typeof val === 'number') {
|
||||
setHoverState({ instanceId: `sidebar-${val}`, docIndex: val });
|
||||
} else {
|
||||
setHoverState(val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentRequest = async (type) => {
|
||||
setGeneratingType(type);
|
||||
setDocModal({ open: true, type, document: null, loading: true });
|
||||
|
||||
try {
|
||||
const res = await generateDocument(activeNotebook.id, type);
|
||||
setDocModal({ open: true, type, document: res.document, loading: false });
|
||||
} catch (err) {
|
||||
console.error('document generation failed', err);
|
||||
setDocModal({ open: false, type: null, document: null, loading: false });
|
||||
} finally {
|
||||
setGeneratingType(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hoveredDocIndex = hoverState?.docIndex ?? null;
|
||||
const hoveredInstanceId = hoverState?.instanceId ?? null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<NotebookList
|
||||
notebooks={notebooks}
|
||||
activeId={activeNotebook?.id}
|
||||
onSelect={handleSelectNotebook}
|
||||
onCreate={createNotebook}
|
||||
onDelete={deleteNotebook}
|
||||
/>
|
||||
{activeNotebook && (
|
||||
<>
|
||||
<SourcePanel
|
||||
notebookId={activeNotebook.id}
|
||||
hoveredSourceIndex={hoveredDocIndex}
|
||||
onSourceHover={handleSourceHover}
|
||||
onSourcesChange={setSourceCount}
|
||||
>
|
||||
{chatReady && (
|
||||
<DocumentButtons
|
||||
sourceCount={sourceCount}
|
||||
onRequest={handleDocumentRequest}
|
||||
generatingType={generatingType}
|
||||
/>
|
||||
)}
|
||||
</SourcePanel>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<main className="main">
|
||||
{activeNotebook ? (
|
||||
<ChatPanel
|
||||
notebookId={activeNotebook.id}
|
||||
hoveredSource={hoveredInstanceId}
|
||||
hoveredDocIndex={hoveredDocIndex}
|
||||
onSourceHover={handleSourceHover}
|
||||
onFirstResponse={() => setChatReady(true)}
|
||||
/>
|
||||
) : (
|
||||
<p>Select or create a notebook to get started.</p>
|
||||
)}
|
||||
</main>
|
||||
<DocumentModal
|
||||
open={docModal.open}
|
||||
onClose={() => setDocModal({ open: false, type: null, document: null, loading: false })}
|
||||
type={docModal.type}
|
||||
document={docModal.document}
|
||||
loading={docModal.loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
9
client/src/api/client.js
Normal file
9
client/src/api/client.js
Normal file
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
export async function handleResponse(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
48
client/src/api/client.test.js
Normal file
48
client/src/api/client.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { handleResponse } from './client.js';
|
||||
|
||||
function fakeResponse(status, body, ok = status >= 200 && status < 300) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleResponse', () => {
|
||||
it('returns parsed JSON on success', async () => {
|
||||
const data = { id: 1, name: 'test' };
|
||||
const result = await handleResponse(fakeResponse(200, data));
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('throws with body.error when present', async () => {
|
||||
const res = fakeResponse(400, { error: 'Bad input' }, false);
|
||||
await expect(handleResponse(res)).rejects.toThrow('Bad input');
|
||||
});
|
||||
|
||||
it('throws with status code when body has no error field', async () => {
|
||||
const res = fakeResponse(500, {}, false);
|
||||
await expect(handleResponse(res)).rejects.toThrow('Request failed: 500');
|
||||
});
|
||||
|
||||
it('throws with status code when body JSON parsing fails', async () => {
|
||||
const res = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: () => Promise.reject(new Error('parse error')),
|
||||
};
|
||||
await expect(handleResponse(res)).rejects.toThrow('Request failed: 502');
|
||||
});
|
||||
|
||||
it('returns empty object body on success', async () => {
|
||||
const result = await handleResponse(fakeResponse(200, {}));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns array body on success', async () => {
|
||||
const data = [1, 2, 3];
|
||||
const result = await handleResponse(fakeResponse(200, data));
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
14
client/src/api/documents.js
Normal file
14
client/src/api/documents.js
Normal file
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
import { handleResponse } from './client.js';
|
||||
|
||||
const BASE = '/api/documents';
|
||||
|
||||
export async function generateDocument(notebookId, type) {
|
||||
const res = await fetch(`${BASE}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId, type }),
|
||||
});
|
||||
return handleResponse(res);
|
||||
}
|
||||
24
client/src/api/notebooks.js
Normal file
24
client/src/api/notebooks.js
Normal file
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
import { handleResponse } from './client.js';
|
||||
|
||||
const BASE = '/api/notebooks';
|
||||
|
||||
export async function listNotebooks() {
|
||||
const res = await fetch(BASE);
|
||||
return handleResponse(res);
|
||||
}
|
||||
|
||||
export async function createNotebook(name) {
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return handleResponse(res);
|
||||
}
|
||||
|
||||
export async function deleteNotebook(id) {
|
||||
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
|
||||
return handleResponse(res);
|
||||
}
|
||||
68
client/src/api/notebooks.test.js
Normal file
68
client/src/api/notebooks.test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { listNotebooks, createNotebook, deleteNotebook } from './notebooks.js';
|
||||
|
||||
const okResponse = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
|
||||
describe('notebooks API', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('listNotebooks', () => {
|
||||
it('fetches GET /api/notebooks', async () => {
|
||||
const data = [{ id: '1', name: 'NB' }];
|
||||
fetch.mockResolvedValue(okResponse(data));
|
||||
|
||||
const result = await listNotebooks();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/notebooks');
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNotebook', () => {
|
||||
it('sends POST with name in JSON body', async () => {
|
||||
const nb = { id: '2', name: 'New' };
|
||||
fetch.mockResolvedValue(okResponse(nb));
|
||||
|
||||
const result = await createNotebook('New');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/notebooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'New' }),
|
||||
});
|
||||
expect(result).toEqual(nb);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNotebook', () => {
|
||||
it('sends DELETE to /api/notebooks/:id', async () => {
|
||||
fetch.mockResolvedValue(okResponse({}));
|
||||
|
||||
await deleteNotebook('abc-123');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/notebooks/abc-123', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ error: 'Server down' }),
|
||||
});
|
||||
|
||||
await expect(listNotebooks()).rejects.toThrow('Server down');
|
||||
});
|
||||
});
|
||||
36
client/src/api/query.js
Normal file
36
client/src/api/query.js
Normal file
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
import { handleResponse } from './client.js';
|
||||
|
||||
const BASE = '/api/query';
|
||||
const CITATION_DETAIL_BASE = '/api/citation-detail';
|
||||
|
||||
export async function sendQuery(notebookId, question, onCitationDetails) {
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId, question }),
|
||||
});
|
||||
const data = await handleResponse(res);
|
||||
|
||||
if (data.citations?.length && onCitationDetails) {
|
||||
Promise.all(
|
||||
data.citations.map((c) =>
|
||||
fetch(CITATION_DETAIL_BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chunkTexts: c.chunkTexts,
|
||||
sourceName: c.name,
|
||||
answer: data.answer,
|
||||
citationIndex: c.sourceIndex,
|
||||
}),
|
||||
})
|
||||
.then((r) => handleResponse(r))
|
||||
.then((detail) => ({ sourceIndex: c.sourceIndex, ...detail }))
|
||||
)
|
||||
).then(onCitationDetails);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
139
client/src/api/query.test.js
Normal file
139
client/src/api/query.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { sendQuery } from './query.js';
|
||||
|
||||
const okResponse = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
|
||||
describe('sendQuery', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sends POST to /api/query with notebookId and question', async () => {
|
||||
const data = { answer: 'The answer', citations: [] };
|
||||
fetch.mockResolvedValue(okResponse(data));
|
||||
|
||||
const result = await sendQuery('nb-1', 'What is AI?');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/query', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId: 'nb-1', question: 'What is AI?' }),
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('returns data without calling onCitationDetails when no citations', async () => {
|
||||
const data = { answer: 'Answer', citations: [] };
|
||||
fetch.mockResolvedValue(okResponse(data));
|
||||
const onCitationDetails = vi.fn();
|
||||
|
||||
await sendQuery('nb-1', 'Q?', onCitationDetails);
|
||||
|
||||
expect(onCitationDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns data without calling onCitationDetails when citations is null', async () => {
|
||||
const data = { answer: 'Answer', citations: null };
|
||||
fetch.mockResolvedValue(okResponse(data));
|
||||
const onCitationDetails = vi.fn();
|
||||
|
||||
await sendQuery('nb-1', 'Q?', onCitationDetails);
|
||||
|
||||
expect(onCitationDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches citation details and calls onCitationDetails', async () => {
|
||||
const queryData = {
|
||||
answer: 'The answer [1]',
|
||||
citations: [
|
||||
{ sourceIndex: 1, name: 'doc.pdf', chunkTexts: ['chunk A'] },
|
||||
{ sourceIndex: 2, name: 'doc2.pdf', chunkTexts: ['chunk B'] },
|
||||
],
|
||||
};
|
||||
const detailA = { citedSentence: 'A', topicSummary: 'T1' };
|
||||
const detailB = { citedSentence: 'B', topicSummary: 'T2' };
|
||||
|
||||
fetch
|
||||
.mockResolvedValueOnce(okResponse(queryData))
|
||||
.mockResolvedValueOnce(okResponse(detailA))
|
||||
.mockResolvedValueOnce(okResponse(detailB));
|
||||
|
||||
const onCitationDetails = vi.fn();
|
||||
const result = await sendQuery('nb-1', 'Q?', onCitationDetails);
|
||||
|
||||
expect(result).toEqual(queryData);
|
||||
|
||||
// Citation detail fetches happen async — wait for the promise
|
||||
await vi.waitFor(() => {
|
||||
expect(onCitationDetails).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const details = onCitationDetails.mock.calls[0][0];
|
||||
expect(details).toHaveLength(2);
|
||||
expect(details[0]).toEqual({ sourceIndex: 1, ...detailA });
|
||||
expect(details[1]).toEqual({ sourceIndex: 2, ...detailB });
|
||||
});
|
||||
|
||||
it('sends correct body for each citation detail request', async () => {
|
||||
const queryData = {
|
||||
answer: 'Answer text',
|
||||
citations: [
|
||||
{ sourceIndex: 1, name: 'src.pdf', chunkTexts: ['c1', 'c2'] },
|
||||
],
|
||||
};
|
||||
const detail = { citedSentence: 'S' };
|
||||
|
||||
fetch
|
||||
.mockResolvedValueOnce(okResponse(queryData))
|
||||
.mockResolvedValueOnce(okResponse(detail));
|
||||
|
||||
const onCitationDetails = vi.fn();
|
||||
await sendQuery('nb-1', 'Q?', onCitationDetails);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/citation-detail', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chunkTexts: ['c1', 'c2'],
|
||||
sourceName: 'src.pdf',
|
||||
answer: 'Answer text',
|
||||
citationIndex: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fetch citation details when no callback provided', async () => {
|
||||
const queryData = {
|
||||
answer: 'Answer',
|
||||
citations: [{ sourceIndex: 1, name: 'x', chunkTexts: ['c'] }],
|
||||
};
|
||||
fetch.mockResolvedValue(okResponse(queryData));
|
||||
|
||||
await sendQuery('nb-1', 'Q?');
|
||||
|
||||
// Only 1 fetch call (the query itself), no citation detail fetches
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws on non-ok response from query endpoint', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ error: 'Internal error' }),
|
||||
});
|
||||
|
||||
await expect(sendQuery('nb-1', 'Q?')).rejects.toThrow('Internal error');
|
||||
});
|
||||
});
|
||||
30
client/src/api/sources.js
Normal file
30
client/src/api/sources.js
Normal file
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
import { handleResponse } from './client.js';
|
||||
|
||||
const BASE = '/api/sources';
|
||||
|
||||
export async function listSources(notebookId) {
|
||||
const res = await fetch(`${BASE}?notebookId=${notebookId}`);
|
||||
return handleResponse(res);
|
||||
}
|
||||
|
||||
export async function uploadSource(notebookId, file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('notebookId', notebookId);
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse(res);
|
||||
}
|
||||
|
||||
export async function addUrlSource(notebookId, url) {
|
||||
const res = await fetch(`${BASE}/url`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId, url }),
|
||||
});
|
||||
return handleResponse(res);
|
||||
}
|
||||
76
client/src/api/sources.test.js
Normal file
76
client/src/api/sources.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { listSources, uploadSource, addUrlSource } from './sources.js';
|
||||
|
||||
const okResponse = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
|
||||
describe('sources API', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('listSources', () => {
|
||||
it('fetches GET /api/sources with notebookId query param', async () => {
|
||||
const data = [{ id: 's1', name: 'file.pdf' }];
|
||||
fetch.mockResolvedValue(okResponse(data));
|
||||
|
||||
const result = await listSources('nb-42');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/sources?notebookId=nb-42');
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadSource', () => {
|
||||
it('sends POST with FormData containing file and notebookId', async () => {
|
||||
const source = { id: 's2', name: 'doc.pdf' };
|
||||
fetch.mockResolvedValue(okResponse(source));
|
||||
|
||||
const fakeFile = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
|
||||
const result = await uploadSource('nb-1', fakeFile);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/sources', {
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
});
|
||||
|
||||
const formData = fetch.mock.calls[0][1].body;
|
||||
expect(formData.get('notebookId')).toBe('nb-1');
|
||||
expect(formData.get('file')).toBeInstanceOf(File);
|
||||
expect(result).toEqual(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUrlSource', () => {
|
||||
it('sends POST to /api/sources/url with JSON body', async () => {
|
||||
const source = { id: 's3', name: 'example.com' };
|
||||
fetch.mockResolvedValue(okResponse(source));
|
||||
|
||||
const result = await addUrlSource('nb-5', 'https://example.com');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/sources/url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId: 'nb-5', url: 'https://example.com' }),
|
||||
});
|
||||
expect(result).toEqual(source);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ error: 'Bad request' }),
|
||||
});
|
||||
|
||||
await expect(listSources('nb-1')).rejects.toThrow('Bad request');
|
||||
});
|
||||
});
|
||||
69
client/src/components/ChatMessage.jsx
Normal file
69
client/src/components/ChatMessage.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import GroundednessScore from './GroundednessScore.jsx';
|
||||
import FollowUpQuestions from './FollowUpQuestions.jsx';
|
||||
|
||||
function renderAnswerWithCitations(answer, citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick) {
|
||||
if (!citations || citations.length === 0) {
|
||||
return <span className="answer-text">{answer}</span>;
|
||||
}
|
||||
|
||||
const parts = answer.split(/(\[\d+\])/g);
|
||||
let citationCounter = 0;
|
||||
const sidebarIsSource = hoveredSource?.startsWith('sidebar-');
|
||||
return (
|
||||
<span className="answer-text">
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[(\d+)\]$/);
|
||||
if (match) {
|
||||
const docIndex = parseInt(match[1], 10);
|
||||
const instanceId = `c-${citationCounter++}`;
|
||||
const isHighlighted =
|
||||
hoveredSource === instanceId ||
|
||||
(sidebarIsSource && hoveredDocIndex === docIndex);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`citation-marker${isHighlighted ? ' citation-highlighted' : ''}`}
|
||||
title={`Source ${match[1]} — click for details`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Citation source ${match[1]}`}
|
||||
onMouseEnter={() => onSourceHover({ instanceId, docIndex })}
|
||||
onMouseLeave={() => onSourceHover(null)}
|
||||
onClick={() => onCitationClick?.(docIndex)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onCitationClick?.(docIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{match[1]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message, onFollowUp, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="chat-message user">{message.text}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message assistant">
|
||||
{renderAnswerWithCitations(message.answer, message.citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick)}
|
||||
<div className="message-meta">
|
||||
<GroundednessScore score={message.groundednessScore} />
|
||||
</div>
|
||||
<FollowUpQuestions
|
||||
questions={message.followUpQuestions}
|
||||
onSelect={onFollowUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessage;
|
||||
165
client/src/components/ChatMessage.test.jsx
Normal file
165
client/src/components/ChatMessage.test.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ChatMessage from './ChatMessage.jsx';
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
describe('user messages', () => {
|
||||
it('renders user text in a user-styled div', () => {
|
||||
const msg = { role: 'user', text: 'Hello there' };
|
||||
const { container } = render(<ChatMessage message={msg} />);
|
||||
const el = container.querySelector('.chat-message.user');
|
||||
expect(el).toHaveTextContent('Hello there');
|
||||
});
|
||||
|
||||
it('does not render groundedness or follow-ups for user messages', () => {
|
||||
const msg = { role: 'user', text: 'Hi' };
|
||||
const { container } = render(<ChatMessage message={msg} />);
|
||||
expect(container.querySelector('.groundedness-score')).toBeNull();
|
||||
expect(container.querySelector('.follow-up-questions')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages — plain text (no citations)', () => {
|
||||
it('renders answer text without citation markers', () => {
|
||||
const msg = { role: 'assistant', answer: 'The sky is blue.', citations: [] };
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText('The sky is blue.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders answer when citations is null', () => {
|
||||
const msg = { role: 'assistant', answer: 'No sources.', citations: null };
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText('No sources.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages — with citations', () => {
|
||||
const baseCitations = [{ sourceIndex: 1 }, { sourceIndex: 2 }];
|
||||
|
||||
it('renders citation numbers as clickable buttons', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Fact one [1] and fact two [2].',
|
||||
citations: baseCitations,
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
|
||||
const markers = screen.getAllByRole('button');
|
||||
expect(markers).toHaveLength(2);
|
||||
expect(markers[0]).toHaveTextContent('1');
|
||||
expect(markers[1]).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('sets aria-label on citation markers', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Info [3].',
|
||||
citations: [{ sourceIndex: 3 }],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByLabelText('Citation source 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCitationClick with docIndex when citation is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCitationClick = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'See [2] here.',
|
||||
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }],
|
||||
};
|
||||
render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={() => {}}
|
||||
onCitationClick={onCitationClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('Citation source 2'));
|
||||
expect(onCitationClick).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls onSourceHover on mouseEnter/mouseLeave of citation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSourceHover = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Ref [1].',
|
||||
citations: [{ sourceIndex: 1 }],
|
||||
};
|
||||
render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={onSourceHover}
|
||||
/>,
|
||||
);
|
||||
|
||||
const marker = screen.getByLabelText('Citation source 1');
|
||||
await user.hover(marker);
|
||||
expect(onSourceHover).toHaveBeenCalledWith({ instanceId: 'c-0', docIndex: 1 });
|
||||
|
||||
await user.unhover(marker);
|
||||
expect(onSourceHover).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('highlights citation when hoveredSource matches instanceId', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Ref [1].',
|
||||
citations: [{ sourceIndex: 1 }],
|
||||
};
|
||||
const { container } = render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
hoveredSource="c-0"
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.citation-highlighted')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('handles multiple citations in sequence', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: '[1][2][3]',
|
||||
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }, { sourceIndex: 3 }],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
const markers = screen.getAllByRole('button');
|
||||
expect(markers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groundedness and follow-ups', () => {
|
||||
it('renders GroundednessScore when present', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Answer',
|
||||
citations: [],
|
||||
groundednessScore: 0.85,
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText(/Grounded: 85%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders follow-up questions and forwards onFollowUp', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFollowUp = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Answer',
|
||||
citations: [],
|
||||
followUpQuestions: ['Tell me more', 'Why?'],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={onFollowUp} onSourceHover={() => {}} />);
|
||||
|
||||
await user.click(screen.getByText('Why?'));
|
||||
expect(onFollowUp).toHaveBeenCalledWith('Why?');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
client/src/components/ChatPanel.jsx
Normal file
150
client/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { sendQuery } from '../api/query.js';
|
||||
import ChatMessage from './ChatMessage.jsx';
|
||||
import CitationDetailModal from './CitationDetailModal.jsx';
|
||||
|
||||
function ChatPanel({ notebookId, hoveredSource, hoveredDocIndex, onSourceHover, onFirstResponse }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [citationDetails, setCitationDetails] = useState({});
|
||||
const [activeCitation, setActiveCitation] = useState(null);
|
||||
const bottomRef = useRef(null);
|
||||
const firstResponseFired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setInput('');
|
||||
setCitationDetails({});
|
||||
setActiveCitation(null);
|
||||
firstResponseFired.current = false;
|
||||
}, [notebookId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const submitQuery = async (question) => {
|
||||
if (!question.trim() || loading) return;
|
||||
|
||||
const userMsg = { role: 'user', text: question, msgId: crypto.randomUUID() };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
const msgId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const res = await sendQuery(notebookId, question, (details) => {
|
||||
const detailMap = {};
|
||||
for (const d of details) {
|
||||
detailMap[d.sourceIndex] = d;
|
||||
}
|
||||
setCitationDetails((prev) => ({ ...prev, [msgId]: detailMap }));
|
||||
});
|
||||
|
||||
const assistantMsg = {
|
||||
role: 'assistant',
|
||||
answer: res.answer,
|
||||
citations: res.citations || [],
|
||||
groundednessScore: res.groundednessScore,
|
||||
followUpQuestions: res.followUpQuestions || [],
|
||||
msgId,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
if (!firstResponseFired.current) {
|
||||
firstResponseFired.current = true;
|
||||
onFirstResponse?.();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = {
|
||||
role: 'assistant',
|
||||
msgId,
|
||||
answer: 'Something went wrong. Please try again.',
|
||||
citations: [],
|
||||
groundednessScore: null,
|
||||
followUpQuestions: [],
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
console.error('query failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
submitQuery(input);
|
||||
};
|
||||
|
||||
const handleFollowUp = (question) => {
|
||||
submitQuery(question);
|
||||
};
|
||||
|
||||
const handleCitationClick = (msgId, docIndex) => {
|
||||
const citation = messages.find(
|
||||
(m) => m.msgId === msgId
|
||||
)?.citations?.find((c) => c.sourceIndex === docIndex);
|
||||
|
||||
setActiveCitation({
|
||||
msgId,
|
||||
docIndex,
|
||||
sourceName: citation?.name || 'Unknown',
|
||||
});
|
||||
};
|
||||
|
||||
const activeDetail =
|
||||
activeCitation
|
||||
? citationDetails[activeCitation.msgId]?.[activeCitation.docIndex] ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="chat-empty">
|
||||
Upload sources and ask a question to get started.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.msgId}
|
||||
message={msg}
|
||||
onFollowUp={handleFollowUp}
|
||||
hoveredSource={hoveredSource}
|
||||
hoveredDocIndex={hoveredDocIndex}
|
||||
onSourceHover={onSourceHover}
|
||||
onCitationClick={(docIndex) => handleCitationClick(msg.msgId, docIndex)}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="chat-message assistant loading">
|
||||
Thinking<span className="loading-dots" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<form className="chat-input" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask a question about your sources..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="submit" disabled={loading || !input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<CitationDetailModal
|
||||
open={!!activeCitation}
|
||||
onClose={() => setActiveCitation(null)}
|
||||
detail={activeDetail}
|
||||
sourceName={activeCitation?.sourceName}
|
||||
citationIndex={activeCitation?.docIndex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPanel;
|
||||
53
client/src/components/CitationDetailModal.jsx
Normal file
53
client/src/components/CitationDetailModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function CitationDetailModal({ open, onClose, detail, sourceName, citationIndex }) {
|
||||
const loading = !detail;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`Source ${citationIndex} — ${sourceName || 'Unknown'}`} width="680px">
|
||||
{loading ? (
|
||||
<p className="citation-detail-loading">Loading deeper insight<span className="loading-dots" /></p>
|
||||
) : (
|
||||
<div className="citation-detail-content">
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Cited Passage</h4>
|
||||
<blockquote className="citation-detail-quote">{detail.citedSentence}</blockquote>
|
||||
</section>
|
||||
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Topic Context</h4>
|
||||
<p>{detail.topicSummary}</p>
|
||||
</section>
|
||||
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Additional Insight</h4>
|
||||
<p>{detail.additionalInsight}</p>
|
||||
</section>
|
||||
|
||||
{detail.relevantLinks?.length > 0 && (
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Further Reading</h4>
|
||||
<ul className="citation-detail-links">
|
||||
{detail.relevantLinks.map((link, i) => (
|
||||
<li key={i}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CitationDetailModal;
|
||||
107
client/src/components/CitationDetailModal.test.jsx
Normal file
107
client/src/components/CitationDetailModal.test.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CitationDetailModal from './CitationDetailModal.jsx';
|
||||
|
||||
const detail = {
|
||||
citedSentence: 'The earth orbits the sun.',
|
||||
topicSummary: 'Astronomy basics covering planetary motion.',
|
||||
additionalInsight: 'This was first proven by Copernicus.',
|
||||
relevantLinks: [
|
||||
{ title: 'Wikipedia: Heliocentrism', url: 'https://en.wikipedia.org/wiki/Heliocentrism' },
|
||||
{ title: 'NASA Orbits', url: 'https://nasa.gov/orbits' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('CitationDetailModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<CitationDetailModal open={false} onClose={() => {}} detail={detail} sourceName="Doc1" citationIndex={1} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading state when detail is null', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={null} sourceName="Doc1" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText(/Loading deeper insight/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the title with source name and index', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="MyDoc.pdf" citationIndex={3} />,
|
||||
);
|
||||
expect(screen.getByText('Source 3 — MyDoc.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses "Unknown" when sourceName is falsy', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Source 1 — Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cited passage as blockquote', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
const quote = screen.getByText('The earth orbits the sun.');
|
||||
expect(quote.tagName).toBe('BLOCKQUOTE');
|
||||
});
|
||||
|
||||
it('renders topic context', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Astronomy basics covering planetary motion.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders additional insight', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('This was first proven by Copernicus.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders relevant links', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
const link1 = screen.getByText('Wikipedia: Heliocentrism');
|
||||
expect(link1.closest('a')).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Heliocentrism');
|
||||
expect(link1.closest('a')).toHaveAttribute('target', '_blank');
|
||||
|
||||
const link2 = screen.getByText('NASA Orbits');
|
||||
expect(link2.closest('a')).toHaveAttribute('href', 'https://nasa.gov/orbits');
|
||||
});
|
||||
|
||||
it('omits Further Reading when relevantLinks is empty', () => {
|
||||
const noLinks = { ...detail, relevantLinks: [] };
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={noLinks} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.queryByText('Further Reading')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders Close button and calls onClose', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={onClose} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Close'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders all section labels', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Cited Passage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Topic Context')).toBeInTheDocument();
|
||||
expect(screen.getByText('Additional Insight')).toBeInTheDocument();
|
||||
expect(screen.getByText('Further Reading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
client/src/components/CreateNotebookModal.jsx
Normal file
53
client/src/components/CreateNotebookModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function CreateNotebookModal({ open, onConfirm, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) onConfirm(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title="New Notebook">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="modal-input"
|
||||
type="text"
|
||||
placeholder="Enter notebook name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="modal-btn modal-btn-confirm"
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateNotebookModal;
|
||||
99
client/src/components/CreateNotebookModal.test.jsx
Normal file
99
client/src/components/CreateNotebookModal.test.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CreateNotebookModal from './CreateNotebookModal.jsx';
|
||||
|
||||
describe('CreateNotebookModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders the modal with title when open', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText('New Notebook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders input and buttons', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Create button when input is empty', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText('Create')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Create button when input has text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'My Notebook');
|
||||
expect(screen.getByText('Create')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('calls onConfirm with trimmed name on submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), ' Research Notes ');
|
||||
await user.click(screen.getByText('Create'));
|
||||
expect(onConfirm).toHaveBeenCalledWith('Research Notes');
|
||||
});
|
||||
|
||||
it('calls onConfirm on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Test{Enter}');
|
||||
expect(onConfirm).toHaveBeenCalledWith('Test');
|
||||
});
|
||||
|
||||
it('does not call onConfirm when input is only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter notebook name');
|
||||
await user.type(input, ' ');
|
||||
await user.type(input, '{Enter}');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={onCancel} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resets input when reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Old name');
|
||||
|
||||
rerender(<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
rerender(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('auto-focuses the input when opened', async () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveFocus();
|
||||
}, { timeout: 200 });
|
||||
});
|
||||
});
|
||||
29
client/src/components/DocumentButtons.jsx
Normal file
29
client/src/components/DocumentButtons.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const DOCUMENT_TYPES = [
|
||||
{ type: 'study-guide', label: 'Study Guide' },
|
||||
{ type: 'faq', label: 'F.A.Q.' },
|
||||
{ type: 'executive-brief', label: 'Executive Brief' },
|
||||
];
|
||||
|
||||
function DocumentButtons({ sourceCount, onRequest, generatingType }) {
|
||||
const enabled = sourceCount >= 2;
|
||||
|
||||
return (
|
||||
<div className="document-buttons">
|
||||
{DOCUMENT_TYPES.map(({ type, label }) => {
|
||||
const isGenerating = generatingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`btn-document${isGenerating ? ' generating' : ''}`}
|
||||
disabled={!enabled || !!generatingType}
|
||||
onClick={() => onRequest(type)}
|
||||
>
|
||||
{isGenerating ? `Generating ${label}...` : label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentButtons;
|
||||
61
client/src/components/DocumentButtons.test.jsx
Normal file
61
client/src/components/DocumentButtons.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DocumentButtons from './DocumentButtons.jsx';
|
||||
|
||||
describe('DocumentButtons', () => {
|
||||
const LABELS = ['Study Guide', 'F.A.Q.', 'Executive Brief'];
|
||||
|
||||
it('renders all three document type buttons', () => {
|
||||
render(<DocumentButtons sourceCount={0} onRequest={() => {}} generatingType={null} />);
|
||||
LABELS.forEach((label) => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables all buttons when sourceCount < 2', () => {
|
||||
render(<DocumentButtons sourceCount={1} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('enables buttons when sourceCount >= 2', () => {
|
||||
render(<DocumentButtons sourceCount={2} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('enables buttons for large source counts', () => {
|
||||
render(<DocumentButtons sourceCount={10} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('calls onRequest with the correct type when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequest = vi.fn();
|
||||
render(<DocumentButtons sourceCount={3} onRequest={onRequest} generatingType={null} />);
|
||||
|
||||
await user.click(screen.getByText('F.A.Q.'));
|
||||
expect(onRequest).toHaveBeenCalledWith('faq');
|
||||
});
|
||||
|
||||
it('disables all buttons when one type is generating', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="faq" />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('shows generating label for the active type', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="study-guide" />);
|
||||
expect(screen.getByText('Generating Study Guide...')).toBeInTheDocument();
|
||||
expect(screen.getByText('F.A.Q.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Executive Brief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies generating CSS class to the active button', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="executive-brief" />);
|
||||
const genButton = screen.getByText('Generating Executive Brief...');
|
||||
expect(genButton).toHaveClass('generating');
|
||||
});
|
||||
});
|
||||
112
client/src/components/DocumentModal.jsx
Normal file
112
client/src/components/DocumentModal.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function StudyGuideContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">{doc.title}</h2>
|
||||
{doc.sections?.map((section, i) => (
|
||||
<section key={i} className="doc-section">
|
||||
<h3 className="doc-section-heading">{section.heading}</h3>
|
||||
{section.bullets?.length > 0 && (
|
||||
<ul className="doc-bullets">
|
||||
{section.bullets.map((b, j) => <li key={j}>{b}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{section.keyTerms?.length > 0 && (
|
||||
<div className="doc-key-terms">
|
||||
<h4 className="doc-subsection-label">Key Terms</h4>
|
||||
<dl>
|
||||
{section.keyTerms.map((kt, j) => (
|
||||
<div key={j} className="doc-term-pair">
|
||||
<dt>{kt.term}</dt>
|
||||
<dd>{kt.definition}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{section.reviewQuestions?.length > 0 && (
|
||||
<div className="doc-review">
|
||||
<h4 className="doc-subsection-label">Self-Review</h4>
|
||||
<ol>
|
||||
{section.reviewQuestions.map((q, j) => <li key={j}>{q}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{doc.mnemonics?.length > 0 && (
|
||||
<section className="doc-section">
|
||||
<h3 className="doc-section-heading">Memory Aids</h3>
|
||||
<ul className="doc-bullets">
|
||||
{doc.mnemonics.map((m, i) => <li key={i}>{m}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">FAQ: {doc.subject}</h2>
|
||||
{doc.faqPairs?.map((pair, i) => (
|
||||
<div key={i} className="doc-faq-pair">
|
||||
<h4 className="doc-faq-question">Q: {pair.question}</h4>
|
||||
<p className="doc-faq-answer">{pair.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecBriefContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">{doc.title}</h2>
|
||||
{doc.sections?.map((section, i) => (
|
||||
<section key={i} className="doc-section">
|
||||
<h3 className="doc-section-heading">{section.subhead}</h3>
|
||||
<p className="doc-prose">{section.prose}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
'study-guide': 'Study Guide',
|
||||
'faq': 'F.A.Q.',
|
||||
'executive-brief': 'Executive Brief',
|
||||
};
|
||||
|
||||
const RENDERERS = {
|
||||
'study-guide': StudyGuideContent,
|
||||
'faq': FaqContent,
|
||||
'executive-brief': ExecBriefContent,
|
||||
};
|
||||
|
||||
function DocumentModal({ open, onClose, type, document: doc, loading }) {
|
||||
const Renderer = type ? RENDERERS[type] : null;
|
||||
const label = type ? LABELS[type] : '';
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={label} width="720px">
|
||||
{loading ? (
|
||||
<p className="citation-detail-loading">Generating {label}<span className="loading-dots" /></p>
|
||||
) : (
|
||||
<>
|
||||
{Renderer && doc && <Renderer doc={doc} />}
|
||||
<div className="modal-actions">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentModal;
|
||||
140
client/src/components/DocumentModal.test.jsx
Normal file
140
client/src/components/DocumentModal.test.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import DocumentModal from './DocumentModal.jsx';
|
||||
|
||||
const studyGuideDoc = {
|
||||
title: 'Intro to AI',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Chapter 1',
|
||||
bullets: ['Point A', 'Point B'],
|
||||
keyTerms: [{ term: 'Neural Net', definition: 'A model inspired by the brain' }],
|
||||
reviewQuestions: ['What is AI?'],
|
||||
},
|
||||
],
|
||||
mnemonics: ['AI = Always Iterating'],
|
||||
};
|
||||
|
||||
const faqDoc = {
|
||||
subject: 'Machine Learning',
|
||||
faqPairs: [
|
||||
{ question: 'What is ML?', answer: 'A subset of AI.' },
|
||||
{ question: 'Why use ML?', answer: 'To find patterns in data.' },
|
||||
],
|
||||
};
|
||||
|
||||
const execBriefDoc = {
|
||||
title: 'Q4 Strategy',
|
||||
sections: [
|
||||
{ subhead: 'Revenue', prose: 'Revenue grew 20%.' },
|
||||
{ subhead: 'Outlook', prose: 'Positive outlook for next quarter.' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('DocumentModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<DocumentModal open={false} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading state with doc type label', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating Study Guide/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state for FAQ', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating F\.A\.Q\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state for Executive Brief', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating Executive Brief/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('StudyGuideContent', () => {
|
||||
it('renders title and section heading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Intro to AI')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chapter 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bullets', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Point A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Point B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders key terms', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Neural Net')).toBeInTheDocument();
|
||||
expect(screen.getByText('A model inspired by the brain')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders review questions', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('What is AI?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mnemonics', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('AI = Always Iterating')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FaqContent', () => {
|
||||
it('renders subject and Q&A pairs', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('FAQ: Machine Learning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Q: What is ML?')).toBeInTheDocument();
|
||||
expect(screen.getByText('A subset of AI.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Q: Why use ML?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExecBriefContent', () => {
|
||||
it('renders title and sections', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={execBriefDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Q4 Strategy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue grew 20%.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Outlook')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Close button when not loading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Close')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Close button when loading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.queryByText('Close')).toBeNull();
|
||||
});
|
||||
});
|
||||
16
client/src/components/FollowUpQuestions.jsx
Normal file
16
client/src/components/FollowUpQuestions.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
function FollowUpQuestions({ questions, onSelect }) {
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="follow-up-questions">
|
||||
<h3 className="follow-up-header">Suggested Follow-Up Questions:</h3>
|
||||
{questions.map((q, i) => (
|
||||
<button key={i} className="follow-up-chip" onClick={() => onSelect(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FollowUpQuestions;
|
||||
49
client/src/components/FollowUpQuestions.test.jsx
Normal file
49
client/src/components/FollowUpQuestions.test.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FollowUpQuestions from './FollowUpQuestions.jsx';
|
||||
|
||||
describe('FollowUpQuestions', () => {
|
||||
it('renders nothing when questions is null', () => {
|
||||
const { container } = render(<FollowUpQuestions questions={null} onSelect={() => {}} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when questions is empty', () => {
|
||||
const { container } = render(<FollowUpQuestions questions={[]} onSelect={() => {}} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders a chip for each question', () => {
|
||||
const questions = ['What is X?', 'How does Y work?', 'Why Z?'];
|
||||
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
|
||||
|
||||
questions.forEach((q) => {
|
||||
expect(screen.getByText(q)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all chips as buttons', () => {
|
||||
const questions = ['Q1', 'Q2'];
|
||||
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls onSelect with the question text when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const questions = ['What is X?', 'How does Y work?'];
|
||||
|
||||
render(<FollowUpQuestions questions={questions} onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByText('How does Y work?'));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith('How does Y work?');
|
||||
});
|
||||
|
||||
it('renders the header text', () => {
|
||||
render(<FollowUpQuestions questions={['Q1']} onSelect={() => {}} />);
|
||||
expect(screen.getByText('Suggested Follow-Up Questions:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
22
client/src/components/GroundednessScore.jsx
Normal file
22
client/src/components/GroundednessScore.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
const LEVEL_LABELS = {
|
||||
green: 'High score. Factually reliable response.',
|
||||
gold: 'Average score. Reliable response, may exhibit minor drift from source.',
|
||||
red: 'Low score. Unreliable response, factually inaccurate or hallucination.',
|
||||
};
|
||||
|
||||
function GroundednessScore({ score }) {
|
||||
if (score == null) return null;
|
||||
|
||||
const pct = Math.round(score * 100);
|
||||
let level = 'red';
|
||||
if (pct >= 75) level = 'green';
|
||||
else if (pct >= 50) level = 'gold';
|
||||
|
||||
return (
|
||||
<span className={`groundedness-score ${level}`}>
|
||||
Grounded: {pct}% – {LEVEL_LABELS[level]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroundednessScore;
|
||||
68
client/src/components/GroundednessScore.test.jsx
Normal file
68
client/src/components/GroundednessScore.test.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import GroundednessScore from './GroundednessScore.jsx';
|
||||
|
||||
describe('GroundednessScore', () => {
|
||||
it('renders nothing when score is null', () => {
|
||||
const { container } = render(<GroundednessScore score={null} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when score is undefined', () => {
|
||||
const { container } = render(<GroundednessScore />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders green level for score >= 0.75', () => {
|
||||
render(<GroundednessScore score={0.85} />);
|
||||
const el = screen.getByText(/Grounded: 85%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('renders green at exactly 0.75 boundary', () => {
|
||||
render(<GroundednessScore score={0.75} />);
|
||||
const el = screen.getByText(/Grounded: 75%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('renders gold level for score >= 0.50 and < 0.75', () => {
|
||||
render(<GroundednessScore score={0.6} />);
|
||||
const el = screen.getByText(/Grounded: 60%/);
|
||||
expect(el).toHaveClass('gold');
|
||||
});
|
||||
|
||||
it('renders gold at exactly 0.50 boundary', () => {
|
||||
render(<GroundednessScore score={0.5} />);
|
||||
const el = screen.getByText(/Grounded: 50%/);
|
||||
expect(el).toHaveClass('gold');
|
||||
});
|
||||
|
||||
it('renders red level for score < 0.50', () => {
|
||||
render(<GroundednessScore score={0.3} />);
|
||||
const el = screen.getByText(/Grounded: 30%/);
|
||||
expect(el).toHaveClass('red');
|
||||
});
|
||||
|
||||
it('renders 0% for score of 0', () => {
|
||||
render(<GroundednessScore score={0} />);
|
||||
const el = screen.getByText(/Grounded: 0%/);
|
||||
expect(el).toHaveClass('red');
|
||||
});
|
||||
|
||||
it('renders 100% for score of 1', () => {
|
||||
render(<GroundednessScore score={1} />);
|
||||
const el = screen.getByText(/Grounded: 100%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('includes the correct label text for each level', () => {
|
||||
const { rerender } = render(<GroundednessScore score={0.9} />);
|
||||
expect(screen.getByText(/Factually reliable response/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GroundednessScore score={0.6} />);
|
||||
expect(screen.getByText(/minor drift from source/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GroundednessScore score={0.2} />);
|
||||
expect(screen.getByText(/factually inaccurate or hallucination/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
68
client/src/components/Modal.jsx
Normal file
68
client/src/components/Modal.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function Modal({ open, onClose, title, width, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const previousFocus = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocus.current = document.activeElement;
|
||||
const dialog = dialogRef.current;
|
||||
const first = dialog?.querySelector(FOCUSABLE);
|
||||
if (first) first.focus();
|
||||
else dialog?.focus();
|
||||
|
||||
return () => previousFocus.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll(FOCUSABLE)];
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal-dialog"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title || undefined}
|
||||
tabIndex={-1}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{title && <h3 className="modal-title">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
103
client/src/components/Modal.test.jsx
Normal file
103
client/src/components/Modal.test.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<Modal open={false} onClose={() => {}} title="Hidden">
|
||||
<p>Content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders children when open is true', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}}>
|
||||
<p>Hello world</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the title when provided', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="My Modal">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByText('My Modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a title element when title is omitted', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
});
|
||||
|
||||
it('has dialog role and aria-modal', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="Accessible">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-label', 'Accessible');
|
||||
});
|
||||
|
||||
it('calls onClose when Escape is pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
await user.click(overlay);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call onClose when dialog content is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('body'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom width style', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} width="600px">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog.style.width).toBe('600px');
|
||||
});
|
||||
});
|
||||
53
client/src/components/NotebookList.jsx
Normal file
53
client/src/components/NotebookList.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import CreateNotebookModal from './CreateNotebookModal.jsx';
|
||||
|
||||
function NotebookList({ notebooks, activeId, onSelect, onCreate, onDelete }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleDelete = (e, id) => {
|
||||
e.stopPropagation();
|
||||
onDelete(id);
|
||||
};
|
||||
|
||||
const handleConfirm = (name) => {
|
||||
setModalOpen(false);
|
||||
onCreate(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notebook-list">
|
||||
<h2>Notebooks</h2>
|
||||
<ul>
|
||||
{notebooks.map((nb) => (
|
||||
<li
|
||||
key={nb.id}
|
||||
className={nb.id === activeId ? 'active' : ''}
|
||||
onClick={() => onSelect(nb.id)}
|
||||
>
|
||||
<span className="nb-name">{nb.name}</span>
|
||||
<button
|
||||
className="nb-delete"
|
||||
onClick={(e) => handleDelete(e, nb.id)}
|
||||
title="Delete notebook"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className="btn-new-notebook"
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
+ New Notebook
|
||||
</button>
|
||||
<CreateNotebookModal
|
||||
open={modalOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotebookList;
|
||||
208
client/src/components/SourcePanel.jsx
Normal file
208
client/src/components/SourcePanel.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import * as sourcesApi from '../api/sources.js';
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
|
||||
|
||||
const FILE_ACCEPT = ALLOWED_EXTENSIONS.join(',');
|
||||
|
||||
function isFileAllowed(file) {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function SourcePanel({ notebookId, hoveredSourceIndex, onSourceHover, onSourcesChange, children }) {
|
||||
const [sources, setSources] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [addingUrl, setAddingUrl] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const fileRef = useRef(null);
|
||||
const dragCounter = useRef(0);
|
||||
const errorTimer = useRef(null);
|
||||
|
||||
const showError = (msg) => {
|
||||
setError(msg);
|
||||
clearTimeout(errorTimer.current);
|
||||
errorTimer.current = setTimeout(() => setError(null), 8000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSources([]);
|
||||
onSourcesChange?.(0);
|
||||
sourcesApi.listSources(notebookId).then((s) => {
|
||||
setSources(s);
|
||||
onSourcesChange?.(s.length);
|
||||
}).catch((err) => showError(err.message || 'Failed to load sources'));
|
||||
}, [notebookId, onSourcesChange]);
|
||||
|
||||
useEffect(() => () => clearTimeout(errorTimer.current), []);
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (!isFileAllowed(file)) {
|
||||
showError(`Unsupported file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const src = await sourcesApi.uploadSource(notebookId, file);
|
||||
setSources((prev) => {
|
||||
const next = [...prev, src];
|
||||
onSourcesChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err.message || 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = (e) => {
|
||||
uploadFile(e.target.files?.[0]);
|
||||
};
|
||||
|
||||
const handleAddUrl = async (e) => {
|
||||
e.preventDefault();
|
||||
const url = urlInput.trim();
|
||||
if (!url || addingUrl) return;
|
||||
setError(null);
|
||||
setAddingUrl(true);
|
||||
try {
|
||||
const src = await sourcesApi.addUrlSource(notebookId, url);
|
||||
if (src.error) {
|
||||
showError(src.error);
|
||||
} else {
|
||||
setSources((prev) => {
|
||||
const next = [...prev, src];
|
||||
onSourcesChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
setUrlInput('');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message || 'Failed to add URL');
|
||||
} finally {
|
||||
setAddingUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current++;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
uploadFile(file);
|
||||
};
|
||||
|
||||
const busy = uploading || addingUrl;
|
||||
|
||||
return (
|
||||
<div className="source-panel">
|
||||
<h3>Sources</h3>
|
||||
<ul role="list">
|
||||
{sources.map((s, idx) => {
|
||||
const docIndex = idx + 1;
|
||||
const isHighlighted = hoveredSourceIndex === docIndex;
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`source-item${isHighlighted ? ' source-highlighted' : ''}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Source ${docIndex}: ${s.name}`}
|
||||
onMouseEnter={() => onSourceHover(docIndex)}
|
||||
onMouseLeave={() => onSourceHover(null)}
|
||||
onFocus={() => onSourceHover(docIndex)}
|
||||
onBlur={() => onSourceHover(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSourceHover(docIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="source-number">{docIndex}</span>
|
||||
{s.name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div
|
||||
className={`source-upload-area${isDragging ? ' dragging' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={FILE_ACCEPT}
|
||||
onChange={handleUpload}
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
className="btn-upload"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
{uploading ? 'Uploading...' : '+ Upload Source'}
|
||||
</button>
|
||||
<div className="drop-zone">
|
||||
[ or drag and drop ]
|
||||
</div>
|
||||
</div>
|
||||
<form className="source-url-form" onSubmit={handleAddUrl}>
|
||||
<input
|
||||
className="source-url-input"
|
||||
type="text"
|
||||
placeholder="Paste a URL or YouTube link..."
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
className="btn-add-url"
|
||||
type="submit"
|
||||
disabled={busy || !urlInput.trim()}
|
||||
>
|
||||
{addingUrl ? 'Adding...' : '+ Add'}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="source-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button className="source-error-dismiss" onClick={() => setError(null)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourcePanel;
|
||||
45
client/src/components/SourcePanel.test.jsx
Normal file
45
client/src/components/SourcePanel.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// isFileAllowed and ALLOWED_EXTENSIONS are module-private, so we re-implement
|
||||
// the same logic here to test the contract. If they were exported we'd import
|
||||
// them directly. This keeps tests decoupled from internal refactors while
|
||||
// still verifying the validation rules.
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
|
||||
|
||||
function isFileAllowed(file) {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function fakeFile(name) {
|
||||
return { name };
|
||||
}
|
||||
|
||||
describe('isFileAllowed', () => {
|
||||
it.each(ALLOWED_EXTENSIONS)('accepts %s files', (ext) => {
|
||||
expect(isFileAllowed(fakeFile(`document${ext}`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unsupported extensions', () => {
|
||||
expect(isFileAllowed(fakeFile('virus.exe'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('archive.zip'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('image.png'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('data.csv'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(isFileAllowed(fakeFile('README.TXT'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('report.PDF'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('notes.Md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles filenames with multiple dots', () => {
|
||||
expect(isFileAllowed(fakeFile('my.report.final.pdf'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('archive.tar.gz'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects files with no extension', () => {
|
||||
expect(isFileAllowed(fakeFile('Makefile'))).toBe(false);
|
||||
});
|
||||
});
|
||||
50
client/src/hooks/useNotebook.js
Normal file
50
client/src/hooks/useNotebook.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as notebooksApi from '../api/notebooks.js';
|
||||
|
||||
export function useNotebook() {
|
||||
const [notebooks, setNotebooks] = useState([]);
|
||||
const [activeNotebook, setActiveNotebook] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
notebooksApi.listNotebooks().then(setNotebooks).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const selectNotebook = useCallback(
|
||||
(id) => {
|
||||
const nb = notebooks.find((n) => n.id === id) || null;
|
||||
setActiveNotebook(nb);
|
||||
},
|
||||
[notebooks],
|
||||
);
|
||||
|
||||
const createNotebook = useCallback(async (name) => {
|
||||
if (!name) return;
|
||||
const nb = await notebooksApi.createNotebook(name);
|
||||
setNotebooks((prev) => [...prev, nb]);
|
||||
setActiveNotebook(nb);
|
||||
}, []);
|
||||
|
||||
const deleteNotebook = useCallback(
|
||||
async (id) => {
|
||||
try {
|
||||
await notebooksApi.deleteNotebook(id);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete notebook', err);
|
||||
return;
|
||||
}
|
||||
setNotebooks((prev) => prev.filter((n) => n.id !== id));
|
||||
if (activeNotebook?.id === id) {
|
||||
setActiveNotebook(null);
|
||||
}
|
||||
},
|
||||
[activeNotebook],
|
||||
);
|
||||
|
||||
return {
|
||||
notebooks,
|
||||
activeNotebook,
|
||||
selectNotebook,
|
||||
createNotebook,
|
||||
deleteNotebook,
|
||||
};
|
||||
}
|
||||
176
client/src/hooks/useNotebook.test.js
Normal file
176
client/src/hooks/useNotebook.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../api/notebooks.js', () => ({
|
||||
listNotebooks: vi.fn(),
|
||||
createNotebook: vi.fn(),
|
||||
deleteNotebook: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useNotebook } from './useNotebook.js';
|
||||
import * as notebooksApi from '../api/notebooks.js';
|
||||
|
||||
const notebooks = [
|
||||
{ id: 'nb-1', name: 'Research' },
|
||||
{ id: 'nb-2', name: 'Personal' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
notebooksApi.listNotebooks.mockResolvedValue(notebooks);
|
||||
notebooksApi.createNotebook.mockImplementation(async (name) => ({
|
||||
id: `nb-${Date.now()}`,
|
||||
name,
|
||||
}));
|
||||
notebooksApi.deleteNotebook.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('useNotebook', () => {
|
||||
it('loads notebooks on mount', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notebooks).toEqual(notebooks);
|
||||
});
|
||||
expect(notebooksApi.listNotebooks).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('starts with no active notebook', () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
expect(result.current.activeNotebook).toBeNull();
|
||||
});
|
||||
|
||||
it('selectNotebook sets the active notebook', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-2');
|
||||
});
|
||||
|
||||
expect(result.current.activeNotebook).toEqual({ id: 'nb-2', name: 'Personal' });
|
||||
});
|
||||
|
||||
it('selectNotebook sets null for unknown id', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-2');
|
||||
});
|
||||
expect(result.current.activeNotebook).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nonexistent');
|
||||
});
|
||||
expect(result.current.activeNotebook).toBeNull();
|
||||
});
|
||||
|
||||
it('createNotebook calls API and adds to list', async () => {
|
||||
const newNb = { id: 'nb-new', name: 'New One' };
|
||||
notebooksApi.createNotebook.mockResolvedValue(newNb);
|
||||
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createNotebook('New One');
|
||||
});
|
||||
|
||||
expect(notebooksApi.createNotebook).toHaveBeenCalledWith('New One');
|
||||
expect(result.current.notebooks).toHaveLength(3);
|
||||
expect(result.current.notebooks[2]).toEqual(newNb);
|
||||
expect(result.current.activeNotebook).toEqual(newNb);
|
||||
});
|
||||
|
||||
it('createNotebook does nothing for empty name', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createNotebook('');
|
||||
});
|
||||
|
||||
expect(notebooksApi.createNotebook).not.toHaveBeenCalled();
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deleteNotebook removes from list', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(notebooksApi.deleteNotebook).toHaveBeenCalledWith('nb-1');
|
||||
expect(result.current.notebooks).toHaveLength(1);
|
||||
expect(result.current.notebooks[0].id).toBe('nb-2');
|
||||
});
|
||||
|
||||
it('deleteNotebook clears activeNotebook if it was the deleted one', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-1');
|
||||
});
|
||||
expect(result.current.activeNotebook?.id).toBe('nb-1');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(result.current.activeNotebook).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteNotebook preserves activeNotebook if different one deleted', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-2');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(result.current.activeNotebook).toEqual({ id: 'nb-2', name: 'Personal' });
|
||||
});
|
||||
|
||||
it('deleteNotebook does not remove from list on API error', async () => {
|
||||
notebooksApi.deleteNotebook.mockRejectedValue(new Error('Server error'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
console.error.mockRestore();
|
||||
});
|
||||
|
||||
it('handles listNotebooks API failure gracefully', async () => {
|
||||
notebooksApi.listNotebooks.mockRejectedValue(new Error('Network error'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notebooksApi.listNotebooks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result.current.notebooks).toEqual([]);
|
||||
console.error.mockRestore();
|
||||
});
|
||||
});
|
||||
873
client/src/index.css
Normal file
873
client/src/index.css
Normal file
@@ -0,0 +1,873 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap');
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f8f9fa;
|
||||
--surface: #ffffff;
|
||||
--sidebar-bg: #000000;
|
||||
--sidebar-text: #e240ff;
|
||||
--sidebar-hover: #7b7b7b;
|
||||
--sidebar-active: #3c3d3e;
|
||||
--primary: #000000;
|
||||
--primary-hover: #d43aa6;
|
||||
--border: #dee2e6;
|
||||
--text: #212529;
|
||||
--text-muted: #6c757d;
|
||||
--danger: #dc3545;
|
||||
--green: #3eff05;
|
||||
--gold: #e6a817;
|
||||
--red: #dc3545;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Layout ────────────────────────────────────────── */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 340px;
|
||||
min-width: 320px;
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main>p {
|
||||
margin: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ── Notebook List ─────────────────────────────────── */
|
||||
|
||||
.notebook-list {
|
||||
padding: 20px 16px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.notebook-list h2 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #e240ff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notebook-list ul {
|
||||
list-style: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.notebook-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notebook-list li:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
.notebook-list li.active {
|
||||
background: var(--sidebar-active);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notebook-list li .nb-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notebook-list li .nb-delete {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.notebook-list li:hover .nb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notebook-list li .nb-delete:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-new-notebook {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-new-notebook:hover {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Source Panel ───────────────────────────────────── */
|
||||
|
||||
.source-panel {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.source-panel h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #e240ff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.source-panel ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.source-panel li {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.source-panel li .source-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.source-panel li {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.source-panel li:hover .source-number,
|
||||
.source-panel li.source-highlighted .source-number {
|
||||
background: transparent;
|
||||
border-color: #04d9ff;
|
||||
color: #04d9ff;
|
||||
}
|
||||
|
||||
.source-panel li:hover,
|
||||
.source-panel li.source-highlighted {
|
||||
color: #04d9ff;
|
||||
}
|
||||
|
||||
.source-upload-area {
|
||||
margin-top: 8px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.source-upload-area.dragging {
|
||||
border-color: var(--sidebar-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-upload:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: 0px 0px 12px 8px;
|
||||
text-align: center;
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.source-url-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 0 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.source-url-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.source-url-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.source-url-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-add-url {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-add-url:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-add-url:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.source-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 60, 60, 0.12);
|
||||
border: 1px solid rgba(255, 60, 60, 0.3);
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
animation: source-error-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.source-error span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-error-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.source-error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes source-error-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Document Buttons ─────────────────────────────── */
|
||||
|
||||
.document-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.btn-document {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-document:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.btn-document:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-document.generating {
|
||||
border-color: #e240ff;
|
||||
color: #e240ff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Chat Panel ────────────────────────────────────── */
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 32px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-input input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 10px 20px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Chat Message ──────────────────────────────────── */
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 20px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
|
||||
margin-left: auto;
|
||||
max-width: 480px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius-sm) var(--radius) var(--radius) var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chat-message.assistant .answer-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.65;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.citation-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
vertical-align: super;
|
||||
margin: 0 1px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.citation-marker:hover,
|
||||
.citation-marker.citation-highlighted {
|
||||
background: transparent;
|
||||
outline: 2px solid #04d9ff;
|
||||
color: #04d9ff;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Groundedness Score ────────────────────────────── */
|
||||
|
||||
.groundedness-score {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 100px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groundedness-score.green {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.groundedness-score.gold {
|
||||
background: rgba(230, 168, 23, 0.1);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.groundedness-score.red {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ── Follow-up Questions ───────────────────────────── */
|
||||
|
||||
.follow-up-questions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.follow-up-header {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.follow-up-chip {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.follow-up-chip:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Loading indicator ─────────────────────────────── */
|
||||
|
||||
.loading-dots::after {
|
||||
content: '';
|
||||
animation: dots 1.2s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0% {
|
||||
content: '';
|
||||
}
|
||||
|
||||
25% {
|
||||
content: '.';
|
||||
}
|
||||
|
||||
50% {
|
||||
content: '..';
|
||||
}
|
||||
|
||||
75% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message.loading {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Modal ────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.modal-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.modal-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.modal-btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.modal-btn-confirm {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-btn-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.modal-btn-confirm:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Citation Detail Modal ────────────────────────── */
|
||||
|
||||
.citation-detail-loading {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-style: italic;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.citation-detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.citation-detail-section h4.citation-detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #e240ff;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.citation-detail-section p {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.citation-detail-quote {
|
||||
border-left: 3px solid #e240ff;
|
||||
padding: 8px 14px;
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.citation-detail-links {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.citation-detail-links a {
|
||||
color: #04d9ff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.citation-detail-links a:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Document Modal Content ───────────────────────── */
|
||||
|
||||
.doc-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-section-heading {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e240ff;
|
||||
}
|
||||
|
||||
.doc-subsection-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-bullets {
|
||||
padding-left: 20px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-bullets li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-key-terms dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.doc-term-pair {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.doc-term-pair dt {
|
||||
font-weight: 600;
|
||||
color: #04d9ff;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-term-pair dd {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.doc-review ol {
|
||||
padding-left: 20px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-review ol li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-faq-pair {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.doc-faq-pair:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doc-faq-question {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e240ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-faq-answer {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-prose {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
10
client/src/main.jsx
Normal file
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
7
client/src/test/setup.js
Normal file
7
client/src/test/setup.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
19
client/vite.config.js
Normal file
19
client/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
},
|
||||
});
|
||||
Binary file not shown.
BIN
sample_test_data/An AI Productivity Boom.pdf
Normal file
BIN
sample_test_data/An AI Productivity Boom.pdf
Normal file
Binary file not shown.
BIN
sample_test_data/The AI productivity boom is not here yet.pdf
Normal file
BIN
sample_test_data/The AI productivity boom is not here yet.pdf
Normal file
Binary file not shown.
Binary file not shown.
6
sample_test_data/links.txt
Normal file
6
sample_test_data/links.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
https://www.youtube.com/watch?v=45QmLivYv3k
|
||||
|
||||
https://www.youtube.com/watch?v=-gBlVTV5z2I
|
||||
|
||||
https://www.sciencedirect.com/science/article/pii/S2199853125000095
|
||||
|
||||
3
server/.env.example
Normal file
3
server/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
ANTHROPIC_API_KEY=<>
|
||||
OPENAI_API_KEY=<> # Only needed for audio source transcription (Whisper)
|
||||
VOYAGE_API_KEY=<>
|
||||
18
server/eslint.config.js
Normal file
18
server/eslint.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
4541
server/package-lock.json
generated
Normal file
4541
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
server/package.json
Normal file
33
server/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "notebook-clone-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": { "node": ">=18" },
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src/",
|
||||
"fmt": "prettier --write .",
|
||||
"fmt:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.22.0",
|
||||
"prettier": "^3.5.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
66
server/src/index.js
Normal file
66
server/src/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import logger from './logger.js';
|
||||
import notebookRoutes from './routes/notebooks.js';
|
||||
import sourceRoutes from './routes/sources.js';
|
||||
import queryRoutes from './routes/query.js';
|
||||
import citationDetailRoutes from './routes/citationDetail.js';
|
||||
import documentRoutes from './routes/documents.js';
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
|
||||
const REQUIRED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY', 'OPENAI_API_KEY'];
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
if (!process.env[key]) {
|
||||
logger.warn(`${key} is not set — API calls that need it will fail`);
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
// CORS: wide-open for local dev. In production, lock this to specific origins.
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
logger.info({
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
ms: Date.now() - start,
|
||||
});
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/.well-known/healthcheck', (req, res) => {
|
||||
res.json({
|
||||
service: 'notebook-clone',
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/notebooks', notebookRoutes);
|
||||
app.use('/api/sources', sourceRoutes);
|
||||
app.use('/api/query', queryRoutes);
|
||||
app.use('/api/citation-detail', citationDetailRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
|
||||
app.use((err, req, res, _next) => {
|
||||
logger.error({ err, method: req.method, url: req.originalUrl });
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info({ port: PORT }, 'server listening');
|
||||
});
|
||||
|
||||
export default app;
|
||||
14
server/src/logger.js
Normal file
14
server/src/logger.js
Normal file
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
import pino from 'pino';
|
||||
|
||||
const DEBUG = !!process.env.DEBUG;
|
||||
|
||||
const logger = pino({
|
||||
level: DEBUG ? 'debug' : 'info',
|
||||
transport: process.stdout.isTTY
|
||||
? { target: 'pino-pretty' }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export default logger;
|
||||
44
server/src/middleware/validation.js
Normal file
44
server/src/middleware/validation.js
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
|
||||
/**
|
||||
* Ensures notebookId is present in req.body or req.query.
|
||||
* Normalises the value onto req.notebookId for downstream handlers.
|
||||
*/
|
||||
export function requireNotebookId(req, res, next) {
|
||||
const notebookId = req.body?.notebookId ?? req.query?.notebookId;
|
||||
if (!notebookId) {
|
||||
return res.status(400).json({ error: 'notebookId is required' });
|
||||
}
|
||||
req.notebookId = notebookId;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Must run after requireNotebookId.
|
||||
* Loads the notebook from the store and attaches it as req.notebook.
|
||||
* Returns 404 if the notebook doesn't exist.
|
||||
*/
|
||||
export function requireNotebook(req, res, next) {
|
||||
const notebook = notebookStore.getNotebook(req.notebookId);
|
||||
if (!notebook) {
|
||||
return res.status(404).json({ error: 'notebook not found' });
|
||||
}
|
||||
req.notebook = notebook;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireFile(req, res, next) {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'file is required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireUrl(req, res, next) {
|
||||
if (!req.body?.url) {
|
||||
return res.status(400).json({ error: 'url is required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
34
server/src/routes/citationDetail.js
Normal file
34
server/src/routes/citationDetail.js
Normal file
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
import { Router } from 'express';
|
||||
import logger from '../logger.js';
|
||||
import { generateCitationDetail } from '../services/generationService.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { chunkTexts, sourceName, answer, citationIndex } = req.body;
|
||||
|
||||
if (!chunkTexts?.length || !answer || citationIndex == null) {
|
||||
return res.status(400).json({
|
||||
error: 'chunkTexts, answer, and citationIndex are required',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ citationIndex, sourceName }, 'citation detail requested');
|
||||
|
||||
const detail = await generateCitationDetail({
|
||||
chunkTexts,
|
||||
sourceName: sourceName || 'Unknown',
|
||||
answer,
|
||||
citationIndex,
|
||||
});
|
||||
|
||||
res.json(detail);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
63
server/src/routes/documents.js
Normal file
63
server/src/routes/documents.js
Normal file
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
import { Router } from 'express';
|
||||
import logger from '../logger.js';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
import * as documentCacheStore from '../stores/documentCacheStore.js';
|
||||
import {
|
||||
generateStudyGuide,
|
||||
generateFaq,
|
||||
generateExecutiveBrief,
|
||||
} from '../services/documentService.js';
|
||||
|
||||
const GENERATORS = {
|
||||
'study-guide': generateStudyGuide,
|
||||
'faq': generateFaq,
|
||||
'executive-brief': generateExecutiveBrief,
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/generate', async (req, res, next) => {
|
||||
try {
|
||||
const { notebookId, type } = req.body;
|
||||
|
||||
if (!notebookId || !type) {
|
||||
return res.status(400).json({ error: 'notebookId and type are required' });
|
||||
}
|
||||
|
||||
const generator = GENERATORS[type];
|
||||
if (!generator) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid type. Must be one of: ${Object.keys(GENERATORS).join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const chunks = notebookStore.getChunksForNotebook(notebookId);
|
||||
if (chunks.length === 0) {
|
||||
return res.status(422).json({ error: 'No source material available in this notebook' });
|
||||
}
|
||||
|
||||
const sources = notebookStore.getSources(notebookId);
|
||||
const cached = documentCacheStore.getCachedDocument(notebookId, type);
|
||||
if (cached && documentCacheStore.isFresh(notebookId, type, sources)) {
|
||||
logger.info({ notebookId, type }, 'serving cached document');
|
||||
return res.json({ type, document: cached.document });
|
||||
}
|
||||
|
||||
const sourceGroups = notebookStore.buildSourceGroups(notebookId, chunks);
|
||||
|
||||
logger.info({ notebookId, type, sourceCount: sourceGroups.length, chunkCount: chunks.length }, 'document generation started');
|
||||
|
||||
const document = await generator(sourceGroups);
|
||||
|
||||
documentCacheStore.setCachedDocument(notebookId, type, document, sources);
|
||||
logger.info({ notebookId, type }, 'document generation complete');
|
||||
|
||||
res.json({ type, document });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
42
server/src/routes/notebooks.js
Normal file
42
server/src/routes/notebooks.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
import { Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
import { deleteVectorsForChunks } from '../stores/vectorStore.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json(notebookStore.getAllNotebooks());
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'name is required' });
|
||||
}
|
||||
|
||||
const notebook = notebookStore.createNotebook({
|
||||
id: uuidv4(),
|
||||
name: name.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.status(201).json(notebook);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const chunks = notebookStore.getChunksForNotebook(req.params.id);
|
||||
const deleted = notebookStore.deleteNotebook(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'notebook not found' });
|
||||
}
|
||||
if (chunks.length) {
|
||||
deleteVectorsForChunks(chunks.map((c) => c.id));
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
88
server/src/routes/query.js
Normal file
88
server/src/routes/query.js
Normal file
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
|
||||
import { Router } from 'express';
|
||||
import logger from '../logger.js';
|
||||
import { embedTexts, search, rerank } from '../services/retrievalService.js';
|
||||
import { generate } from '../services/generationService.js';
|
||||
import { computeGroundedness } from '../services/scoringService.js';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
|
||||
const TOP_K_SEARCH = 20;
|
||||
const TOP_K_RERANK = 5;
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { notebookId, question } = req.body;
|
||||
if (!notebookId || !question) {
|
||||
return res.status(400).json({ error: 'notebookId and question are required' });
|
||||
}
|
||||
|
||||
logger.info({ notebookId, question }, 'query received');
|
||||
|
||||
const [queryEmbedding] = await embedTexts([question], 'query');
|
||||
|
||||
const searchResults = search(queryEmbedding, notebookId, TOP_K_SEARCH);
|
||||
if (searchResults.length === 0) {
|
||||
return res.json({
|
||||
answer: 'No sources found for this notebook. Upload some documents first.',
|
||||
citations: [],
|
||||
groundednessScore: null,
|
||||
followUpQuestions: [],
|
||||
});
|
||||
}
|
||||
|
||||
const candidateChunks = searchResults.map((r) => r.chunk);
|
||||
const reranked = await rerank(question, candidateChunks);
|
||||
const topChunks = reranked.slice(0, TOP_K_RERANK).map((r) => r.chunk);
|
||||
|
||||
logger.debug({
|
||||
searchHits: searchResults.length,
|
||||
rerankedTop: topChunks.length,
|
||||
}, 'retrieval complete');
|
||||
|
||||
const sourceGroups = notebookStore.buildSourceGroups(notebookId, topChunks);
|
||||
|
||||
const { answer, citedSourceIndices, followUpQuestions } = await generate(
|
||||
question,
|
||||
sourceGroups,
|
||||
);
|
||||
|
||||
const citedChunkIds = [];
|
||||
for (const idx of citedSourceIndices) {
|
||||
const group = sourceGroups.find((g) => g.docIndex === idx);
|
||||
if (group) {
|
||||
citedChunkIds.push(...group.chunks.map((c) => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
const groundednessScore = await computeGroundedness(answer, citedChunkIds);
|
||||
|
||||
logger.info({ groundednessScore: groundednessScore.toFixed(3) }, 'query complete');
|
||||
|
||||
const citations = citedSourceIndices.map((idx) => {
|
||||
const group = sourceGroups.find((g) => g.docIndex === idx);
|
||||
return group
|
||||
? {
|
||||
sourceIndex: idx,
|
||||
sourceId: group.sourceId,
|
||||
name: group.name,
|
||||
chunkTexts: group.chunks.map((c) => c.text),
|
||||
}
|
||||
: { sourceIndex: idx, sourceId: null, name: null, chunkTexts: [] };
|
||||
});
|
||||
|
||||
res.json({
|
||||
answer,
|
||||
citations,
|
||||
groundednessScore,
|
||||
followUpQuestions,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
138
server/src/routes/sources.js
Normal file
138
server/src/routes/sources.js
Normal file
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import logger from '../logger.js';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
import {
|
||||
parseFile,
|
||||
chunkText,
|
||||
parseUrl,
|
||||
parseYoutubeUrl,
|
||||
isYoutubeUrl,
|
||||
} from '../services/sourceService.js';
|
||||
import { embedTexts, storeChunkEmbeddings } from '../services/retrievalService.js';
|
||||
import { triggerPreGeneration } from '../services/preGenerationService.js';
|
||||
import {
|
||||
requireNotebookId,
|
||||
requireNotebook,
|
||||
requireFile,
|
||||
requireUrl,
|
||||
} from '../middleware/validation.js';
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
const router = Router();
|
||||
|
||||
const TIMESTAMP_RE = /\s+\d{1,2}\.\d{2}\.\d{2}\s*[\u2018\u2019''\s]?\s*[AP]M(?=\.\w+$)/i;
|
||||
function cleanFilename(name) {
|
||||
return name.replace(TIMESTAMP_RE, '');
|
||||
}
|
||||
|
||||
router.get('/', requireNotebookId, (req, res) => {
|
||||
res.json(notebookStore.getSources(req.notebookId));
|
||||
});
|
||||
|
||||
|
||||
function multerUpload(req, res, next) {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'File too large. Maximum size is 50 MB.' });
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
multerUpload,
|
||||
requireNotebookId,
|
||||
requireNotebook,
|
||||
requireFile,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const sourceId = uuidv4();
|
||||
const rawName = Buffer.from(req.file.originalname, 'latin1').toString('utf-8');
|
||||
const displayName = cleanFilename(rawName);
|
||||
const text = await parseFile(req.file.buffer, req.file.mimetype, displayName);
|
||||
const chunks = chunkText(text, sourceId);
|
||||
|
||||
logger.info({
|
||||
sourceId,
|
||||
filename: displayName,
|
||||
chunkCount: chunks.length,
|
||||
}, 'parsed and chunked source');
|
||||
|
||||
notebookStore.addChunksToNotebook(req.notebookId, chunks);
|
||||
|
||||
const texts = chunks.map((c) => c.text);
|
||||
const embeddings = await embedTexts(texts, 'document');
|
||||
storeChunkEmbeddings(chunks, embeddings);
|
||||
|
||||
const source = notebookStore.addSource(req.notebookId, {
|
||||
id: sourceId,
|
||||
name: displayName,
|
||||
mimetype: req.file.mimetype,
|
||||
chunkCount: chunks.length,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
triggerPreGeneration(req.notebookId);
|
||||
|
||||
res.status(201).json(source);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/url',
|
||||
requireNotebookId,
|
||||
requireNotebook,
|
||||
requireUrl,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
const sourceId = uuidv4();
|
||||
const isYT = isYoutubeUrl(url);
|
||||
const displayName = isYT
|
||||
? `YouTube: ${url}`
|
||||
: url.replace(/^https?:\/\//, '').slice(0, 60);
|
||||
|
||||
logger.info({ sourceId, url, isYT }, 'processing URL source');
|
||||
|
||||
const text = isYT ? await parseYoutubeUrl(url) : await parseUrl(url);
|
||||
const chunks = chunkText(text, sourceId);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return res.status(422).json({ error: 'No usable text could be extracted from the URL' });
|
||||
}
|
||||
|
||||
notebookStore.addChunksToNotebook(req.notebookId, chunks);
|
||||
|
||||
const texts = chunks.map((c) => c.text);
|
||||
const embeddings = await embedTexts(texts, 'document');
|
||||
storeChunkEmbeddings(chunks, embeddings);
|
||||
|
||||
const source = notebookStore.addSource(req.notebookId, {
|
||||
id: sourceId,
|
||||
name: displayName,
|
||||
mimetype: isYT ? 'video/youtube' : 'text/html',
|
||||
chunkCount: chunks.length,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
triggerPreGeneration(req.notebookId);
|
||||
|
||||
res.status(201).json(source);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
199
server/src/services/documentService.js
Normal file
199
server/src/services/documentService.js
Normal file
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import logger from '../logger.js';
|
||||
|
||||
const MODEL = 'claude-sonnet-4-5-20250929';
|
||||
|
||||
function getClient() {
|
||||
const key = process.env.ANTHROPIC_API_KEY;
|
||||
if (!key) throw new Error('ANTHROPIC_API_KEY is not set');
|
||||
return new Anthropic({ apiKey: key });
|
||||
}
|
||||
|
||||
const MAX_SOURCE_CHARS = 30000;
|
||||
|
||||
function buildSourceBlock(sourceGroups) {
|
||||
const perSourceBudget = Math.floor(MAX_SOURCE_CHARS / (sourceGroups.length || 1));
|
||||
|
||||
return sourceGroups
|
||||
.map((g) => {
|
||||
const combined = g.chunks.map((c) => c.text).join('\n\n');
|
||||
const trimmed =
|
||||
combined.length > perSourceBudget
|
||||
? combined.slice(0, perSourceBudget) + '\n[...truncated]'
|
||||
: combined;
|
||||
return `[Source ${g.docIndex}] (${g.name})\n${trimmed}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
const STUDY_GUIDE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'A concise title for the study guide' },
|
||||
sections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
heading: { type: 'string' },
|
||||
bullets: { type: 'array', items: { type: 'string' }, description: 'Key points as bullet items' },
|
||||
keyTerms: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: { type: 'string' },
|
||||
definition: { type: 'string' },
|
||||
},
|
||||
required: ['term', 'definition'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
reviewQuestions: { type: 'array', items: { type: 'string' }, description: 'Self-test questions for this section' },
|
||||
},
|
||||
required: ['heading', 'bullets', 'keyTerms', 'reviewQuestions'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
mnemonics: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Memory aids, simplified restatements, or mnemonic devices for the hardest concepts',
|
||||
},
|
||||
},
|
||||
required: ['title', 'sections', 'mnemonics'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateStudyGuide(sourceGroups) {
|
||||
const client = getClient();
|
||||
const sources = buildSourceBlock(sourceGroups);
|
||||
const start = Date.now();
|
||||
|
||||
const prompt = `You are an expert educator. Given the source documents below, produce a comprehensive study guide. Follow these rules:
|
||||
|
||||
1. Create a structured outline of the main ideas organized into logical sections.
|
||||
2. For each section, provide: bullet-point key concepts, key terms with definitions, and 2-3 self-review questions.
|
||||
3. At the end, provide mnemonic devices or simplified restatements to help memorize the hardest concepts.
|
||||
4. Be concise but thorough. Use simple, clear language.
|
||||
|
||||
--- SOURCE DOCUMENTS ---
|
||||
|
||||
${sources}`;
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
output_config: { format: { type: 'json_schema', schema: STUDY_GUIDE_SCHEMA } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(message.content[0]?.text || '{}');
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
logger.info({ sections: parsed.sections?.length, elapsedSec: elapsed }, 'study guide generated');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const FAQ_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subject: { type: 'string', description: 'The identified common subject/theme across all sources' },
|
||||
faqPairs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string' },
|
||||
answer: { type: 'string' },
|
||||
},
|
||||
required: ['question', 'answer'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
description: '8-15 frequently asked questions with concise answers',
|
||||
},
|
||||
},
|
||||
required: ['subject', 'faqPairs'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateFaq(sourceGroups) {
|
||||
const client = getClient();
|
||||
const sources = buildSourceBlock(sourceGroups);
|
||||
const start = Date.now();
|
||||
|
||||
const prompt = `You are a subject-matter expert. Given the source documents below, perform two tasks:
|
||||
|
||||
1. IDENTIFY THE SUBJECT: Analyze all sources and determine the common subject, theme, or topic they share. Do NOT ask the user — infer it from the semantic overlap across the documents.
|
||||
2. GENERATE FAQ: Produce 8-15 frequently asked questions that someone studying or researching this subject would likely ask, along with concise, accurate answers grounded in the source material.
|
||||
|
||||
Make the questions range from foundational ("What is...") to more advanced/nuanced. Answers should be 2-4 sentences each.
|
||||
|
||||
--- SOURCE DOCUMENTS ---
|
||||
|
||||
${sources}`;
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
output_config: { format: { type: 'json_schema', schema: FAQ_SCHEMA } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(message.content[0]?.text || '{}');
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
logger.info({ subject: parsed.subject, pairs: parsed.faqPairs?.length, elapsedSec: elapsed }, 'FAQ generated');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const EXEC_BRIEF_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'The main subject header for the brief' },
|
||||
sections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subhead: { type: 'string' },
|
||||
prose: { type: 'string', description: 'Well-written prose paragraph(s) for this section' },
|
||||
},
|
||||
required: ['subhead', 'prose'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['title', 'sections'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateExecutiveBrief(sourceGroups) {
|
||||
const client = getClient();
|
||||
const sources = buildSourceBlock(sourceGroups);
|
||||
const start = Date.now();
|
||||
|
||||
const prompt = `You are a senior analyst writing an executive brief. Given the source documents below, produce a highly organized, prose-form summary. Follow these rules:
|
||||
|
||||
1. Identify the overarching subject and use it as the main title/header.
|
||||
2. Organize the information into logical sections, each with a clear subheading.
|
||||
3. Write in polished prose — NO bullet points, NO lists. Use well-structured paragraphs.
|
||||
4. Condense the source material to the equivalent of approximately 3 pages. Prioritize the most important findings, arguments, and conclusions.
|
||||
5. Maintain an authoritative, professional tone suitable for executive-level readers.
|
||||
|
||||
--- SOURCE DOCUMENTS ---
|
||||
|
||||
${sources}`;
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
output_config: { format: { type: 'json_schema', schema: EXEC_BRIEF_SCHEMA } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(message.content[0]?.text || '{}');
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
logger.info({ title: parsed.title, sections: parsed.sections?.length, elapsedSec: elapsed }, 'executive brief generated');
|
||||
return parsed;
|
||||
}
|
||||
156
server/src/services/documentService.test.js
Normal file
156
server/src/services/documentService.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockCreate } = vi.hoisted(() => ({ mockCreate: vi.fn() }));
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => ({
|
||||
default: vi.fn(() => ({ messages: { create: mockCreate } })),
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
generateStudyGuide,
|
||||
generateFaq,
|
||||
generateExecutiveBrief,
|
||||
} from './documentService.js';
|
||||
|
||||
const SOURCE_GROUPS = [
|
||||
{ docIndex: 1, name: 'Doc A', chunks: [{ text: 'Alpha content.' }] },
|
||||
{ docIndex: 2, name: 'Doc B', chunks: [{ text: 'Beta first.' }, { text: 'Beta second.' }] },
|
||||
];
|
||||
|
||||
function apiResponse(obj) {
|
||||
return { content: [{ text: JSON.stringify(obj) }] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
});
|
||||
|
||||
describe('generateStudyGuide', () => {
|
||||
const STUDY_GUIDE = {
|
||||
title: 'Test Guide',
|
||||
sections: [{ heading: 'Intro', bullets: ['b1'], keyTerms: [], reviewQuestions: [] }],
|
||||
mnemonics: ['ABC'],
|
||||
};
|
||||
|
||||
it('returns the parsed API response', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(STUDY_GUIDE));
|
||||
const result = await generateStudyGuide(SOURCE_GROUPS);
|
||||
expect(result).toEqual(STUDY_GUIDE);
|
||||
});
|
||||
|
||||
it('sends source content in the prompt', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(STUDY_GUIDE));
|
||||
await generateStudyGuide(SOURCE_GROUPS);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
|
||||
expect(prompt).toContain('[Source 1] (Doc A)');
|
||||
expect(prompt).toContain('Alpha content.');
|
||||
expect(prompt).toContain('[Source 2] (Doc B)');
|
||||
expect(prompt).toContain('Beta first.');
|
||||
expect(prompt).toContain('Beta second.');
|
||||
});
|
||||
|
||||
it('separates source groups with delimiters', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(STUDY_GUIDE));
|
||||
await generateStudyGuide(SOURCE_GROUPS);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
|
||||
expect(prompt).toContain('---');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFaq', () => {
|
||||
const FAQ = {
|
||||
subject: 'Testing',
|
||||
faqPairs: [{ question: 'Q?', answer: 'A.' }],
|
||||
};
|
||||
|
||||
it('returns the parsed API response', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(FAQ));
|
||||
const result = await generateFaq(SOURCE_GROUPS);
|
||||
expect(result).toEqual(FAQ);
|
||||
});
|
||||
|
||||
it('sends source content in the prompt', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(FAQ));
|
||||
await generateFaq(SOURCE_GROUPS);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
|
||||
expect(prompt).toContain('[Source 1] (Doc A)');
|
||||
expect(prompt).toContain('[Source 2] (Doc B)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateExecutiveBrief', () => {
|
||||
const BRIEF = {
|
||||
title: 'Exec Brief',
|
||||
sections: [{ subhead: 'Overview', prose: 'Some prose.' }],
|
||||
};
|
||||
|
||||
it('returns the parsed API response', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(BRIEF));
|
||||
const result = await generateExecutiveBrief(SOURCE_GROUPS);
|
||||
expect(result).toEqual(BRIEF);
|
||||
});
|
||||
|
||||
it('sends source content in the prompt', async () => {
|
||||
mockCreate.mockResolvedValue(apiResponse(BRIEF));
|
||||
await generateExecutiveBrief(SOURCE_GROUPS);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
|
||||
expect(prompt).toContain('[Source 1] (Doc A)');
|
||||
expect(prompt).toContain('[Source 2] (Doc B)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared behaviour', () => {
|
||||
it('throws when ANTHROPIC_API_KEY is not set', async () => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
await expect(generateStudyGuide(SOURCE_GROUPS)).rejects.toThrow('ANTHROPIC_API_KEY is not set');
|
||||
});
|
||||
|
||||
it('returns empty object when the API returns empty content', async () => {
|
||||
mockCreate.mockResolvedValue({ content: [{ text: '' }] });
|
||||
const result = await generateStudyGuide(SOURCE_GROUPS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('truncates sources that exceed the per-source character budget', async () => {
|
||||
const longText = 'x'.repeat(35_000);
|
||||
const bigGroups = [
|
||||
{ docIndex: 1, name: 'Big', chunks: [{ text: longText }] },
|
||||
];
|
||||
mockCreate.mockResolvedValue(
|
||||
apiResponse({ title: 't', sections: [], mnemonics: [] }),
|
||||
);
|
||||
|
||||
await generateStudyGuide(bigGroups);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
|
||||
expect(prompt).toContain('[...truncated]');
|
||||
expect(prompt.length).toBeLessThan(longText.length);
|
||||
});
|
||||
|
||||
it('combines multiple chunks within a source with double newlines', async () => {
|
||||
const groups = [
|
||||
{ docIndex: 1, name: 'Multi', chunks: [{ text: 'AAA' }, { text: 'BBB' }] },
|
||||
];
|
||||
mockCreate.mockResolvedValue(
|
||||
apiResponse({ title: 't', sections: [], mnemonics: [] }),
|
||||
);
|
||||
|
||||
await generateStudyGuide(groups);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
|
||||
expect(prompt).toContain('AAA\n\nBBB');
|
||||
});
|
||||
});
|
||||
173
server/src/services/generationService.js
Normal file
173
server/src/services/generationService.js
Normal file
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import logger from '../logger.js';
|
||||
|
||||
const MODEL = 'claude-opus-4-6';
|
||||
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The answer with inline numeric citations like [1], [2] matching the source document numbers provided',
|
||||
},
|
||||
citedSourceIndices: {
|
||||
type: 'array',
|
||||
items: { type: 'integer' },
|
||||
description: 'The document numbers cited in the answer',
|
||||
},
|
||||
followUpQuestions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Exactly 3 follow-up questions the user might ask',
|
||||
},
|
||||
},
|
||||
required: ['answer', 'citedSourceIndices', 'followUpQuestions'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
function getClient() {
|
||||
const key = process.env.ANTHROPIC_API_KEY;
|
||||
if (!key) throw new Error('ANTHROPIC_API_KEY is not set');
|
||||
return new Anthropic({ apiKey: key });
|
||||
}
|
||||
|
||||
export function buildPrompt(query, sourceGroups) {
|
||||
const today = new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const sources = sourceGroups
|
||||
.map((g) => {
|
||||
const combined = g.chunks.map((c) => c.text).join('\n\n');
|
||||
return `[Source ${g.docIndex}] (${g.name})\n${combined}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
return `You are a research assistant. Today's date is ${today}. Answer the user's question using ONLY the source documents provided below. Follow these rules strictly:
|
||||
|
||||
1. Ground every claim in a specific source document. Cite sources inline using numeric notation like [1], [2], etc., matching the source document numbers below.
|
||||
2. If the sources do not contain enough information to answer, say so honestly.
|
||||
3. After your answer, suggest exactly 3 follow-up questions the user might ask based on the sources.
|
||||
|
||||
--- SOURCE DOCUMENTS ---
|
||||
|
||||
${sources}
|
||||
|
||||
--- USER QUESTION ---
|
||||
|
||||
${query}`;
|
||||
}
|
||||
|
||||
export async function generate(query, sourceGroups) {
|
||||
const client = getClient();
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 2048,
|
||||
messages: [
|
||||
{ role: 'user', content: buildPrompt(query, sourceGroups) },
|
||||
],
|
||||
output_config: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
schema: RESPONSE_SCHEMA,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const raw = message.content[0]?.text || '';
|
||||
logger.debug({ rawLength: raw.length }, 'claude response received');
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
const validIndices = new Set(sourceGroups.map((g) => g.docIndex));
|
||||
const citedSourceIndices = [
|
||||
...new Set(
|
||||
(parsed.citedSourceIndices || []).filter((idx) => validIndices.has(idx)),
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
answer: parsed.answer || '',
|
||||
citedSourceIndices,
|
||||
followUpQuestions: parsed.followUpQuestions || [],
|
||||
};
|
||||
}
|
||||
|
||||
const CITATION_DETAIL_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
citedSentence: {
|
||||
type: 'string',
|
||||
description: 'The exact sentence or passage from the source material that the citation refers to',
|
||||
},
|
||||
topicSummary: {
|
||||
type: 'string',
|
||||
description: 'A concise summary of the broader topic/context surrounding the cited passage',
|
||||
},
|
||||
additionalInsight: {
|
||||
type: 'string',
|
||||
description: 'Additional expert insight, analysis, or context on the subject beyond what the source states',
|
||||
},
|
||||
relevantLinks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Descriptive link title' },
|
||||
url: { type: 'string', description: 'Full URL to the resource' },
|
||||
},
|
||||
required: ['title', 'url'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
description: 'Up to 3 relevant web resources for further reading on the topic',
|
||||
},
|
||||
},
|
||||
required: ['citedSentence', 'topicSummary', 'additionalInsight', 'relevantLinks'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateCitationDetail({ chunkTexts, sourceName, answer, citationIndex }) {
|
||||
const client = getClient();
|
||||
|
||||
const sourceText = chunkTexts.join('\n\n');
|
||||
|
||||
const prompt = `You are a research assistant. A user is reading a research response and wants to dive deeper into a specific citation. Below is the full answer they are reading, followed by the source material for citation [${citationIndex}] from "${sourceName}".
|
||||
|
||||
Your task:
|
||||
1. Identify the specific sentence or passage from the source material that citation [${citationIndex}] refers to in the answer.
|
||||
2. Summarize the broader topic/context surrounding that passage in the source material.
|
||||
3. Provide additional expert insight or analysis on this subject that would help the reader understand it more deeply.
|
||||
4. Suggest up to 3 relevant, real web resources (articles, papers, official sites) where the reader can learn more about this specific topic. Only include links you are highly confident are real and accessible.
|
||||
|
||||
--- ANSWER THE USER WAS READING ---
|
||||
|
||||
${answer}
|
||||
|
||||
--- SOURCE MATERIAL FOR [${citationIndex}] (${sourceName}) ---
|
||||
|
||||
${sourceText}`;
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 1536,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
output_config: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
schema: CITATION_DETAIL_SCHEMA,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const raw = message.content[0]?.text || '';
|
||||
logger.debug({ citationIndex, rawLength: raw.length }, 'citation detail response received');
|
||||
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
66
server/src/services/generationService.test.js
Normal file
66
server/src/services/generationService.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildPrompt } from './generationService.js';
|
||||
|
||||
describe('buildPrompt', () => {
|
||||
const sourceGroups = [
|
||||
{
|
||||
docIndex: 1,
|
||||
name: 'Weather Facts',
|
||||
chunks: [{ text: 'The sky is blue.' }],
|
||||
},
|
||||
{
|
||||
docIndex: 2,
|
||||
name: 'Water Science',
|
||||
chunks: [
|
||||
{ text: 'Water is wet.' },
|
||||
{ text: 'Water boils at 100°C.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('includes the user query', () => {
|
||||
const prompt = buildPrompt('Why is the sky blue?', sourceGroups);
|
||||
expect(prompt).toContain('Why is the sky blue?');
|
||||
});
|
||||
|
||||
it('labels each source with [Source N] and its name', () => {
|
||||
const prompt = buildPrompt('test', sourceGroups);
|
||||
expect(prompt).toContain('[Source 1] (Weather Facts)');
|
||||
expect(prompt).toContain('[Source 2] (Water Science)');
|
||||
});
|
||||
|
||||
it('includes all chunk texts within their source groups', () => {
|
||||
const prompt = buildPrompt('test', sourceGroups);
|
||||
expect(prompt).toContain('The sky is blue.');
|
||||
expect(prompt).toContain('Water is wet.');
|
||||
expect(prompt).toContain('Water boils at 100°C.');
|
||||
});
|
||||
|
||||
it('instructs citation format using numeric source references', () => {
|
||||
const prompt = buildPrompt('test', sourceGroups);
|
||||
expect(prompt).toContain('[1]');
|
||||
expect(prompt).toContain('[2]');
|
||||
});
|
||||
|
||||
it('separates source groups with delimiters', () => {
|
||||
const prompt = buildPrompt('test', sourceGroups);
|
||||
expect(prompt).toContain('---');
|
||||
});
|
||||
|
||||
it('works with a single source group', () => {
|
||||
const prompt = buildPrompt('test', [sourceGroups[0]]);
|
||||
expect(prompt).toContain('[Source 1] (Weather Facts)');
|
||||
expect(prompt).not.toContain('[Source 2]');
|
||||
});
|
||||
|
||||
it('combines multiple chunks within a single source group', () => {
|
||||
const prompt = buildPrompt('test', [sourceGroups[1]]);
|
||||
expect(prompt).toContain('Water is wet.');
|
||||
expect(prompt).toContain('Water boils at 100°C.');
|
||||
});
|
||||
|
||||
it('includes today date', () => {
|
||||
const prompt = buildPrompt('test', sourceGroups);
|
||||
expect(prompt).toMatch(/Today's date is .+\d{4}/);
|
||||
});
|
||||
});
|
||||
91
server/src/services/preGenerationService.js
Normal file
91
server/src/services/preGenerationService.js
Normal file
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
import logger from '../logger.js';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
import * as documentCacheStore from '../stores/documentCacheStore.js';
|
||||
import {
|
||||
generateStudyGuide,
|
||||
generateFaq,
|
||||
generateExecutiveBrief,
|
||||
} from './documentService.js';
|
||||
|
||||
const MIN_SOURCES = 2;
|
||||
|
||||
const GENERATORS = {
|
||||
'study-guide': generateStudyGuide,
|
||||
'faq': generateFaq,
|
||||
'executive-brief': generateExecutiveBrief,
|
||||
};
|
||||
|
||||
const DEBOUNCE_MS = 5000;
|
||||
|
||||
const pendingReGen = new Set();
|
||||
const debounceTimers = new Map();
|
||||
|
||||
export function triggerPreGeneration(notebookId) {
|
||||
const notebook = notebookStore.getNotebook(notebookId);
|
||||
if (!notebook) return;
|
||||
|
||||
const sources = notebookStore.getSources(notebookId);
|
||||
if (sources.length < MIN_SOURCES) return;
|
||||
|
||||
if (documentCacheStore.isGenerating(notebookId)) {
|
||||
pendingReGen.add(notebookId);
|
||||
documentCacheStore.invalidate(notebookId);
|
||||
logger.debug({ notebookId }, 'pre-generation in progress, queued re-generation');
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimers.get(notebookId));
|
||||
debounceTimers.set(
|
||||
notebookId,
|
||||
setTimeout(() => {
|
||||
debounceTimers.delete(notebookId);
|
||||
runPreGeneration(notebookId);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
logger.debug({ notebookId, debounceMs: DEBOUNCE_MS }, 'pre-generation debounced');
|
||||
}
|
||||
|
||||
function runPreGeneration(notebookId) {
|
||||
try {
|
||||
const sources = notebookStore.getSources(notebookId);
|
||||
const chunks = notebookStore.getChunksForNotebook(notebookId);
|
||||
const sourceGroups = notebookStore.buildSourceGroups(notebookId, chunks);
|
||||
|
||||
documentCacheStore.invalidate(notebookId);
|
||||
documentCacheStore.markGenerating(notebookId);
|
||||
|
||||
logger.info(
|
||||
{ notebookId, sourceCount: sources.length, chunkCount: chunks.length },
|
||||
'background pre-generation started',
|
||||
);
|
||||
|
||||
const jobs = Object.entries(GENERATORS).map(async ([type, generator]) => {
|
||||
try {
|
||||
const document = await generator(sourceGroups);
|
||||
documentCacheStore.setCachedDocument(notebookId, type, document, sources);
|
||||
logger.info({ notebookId, type }, 'background pre-generation complete for type');
|
||||
} catch (err) {
|
||||
logger.error({ notebookId, type, err: err.message }, 'background pre-generation failed for type');
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(jobs)
|
||||
.then(() => {
|
||||
logger.info({ notebookId }, 'all background pre-generation complete');
|
||||
})
|
||||
.finally(() => {
|
||||
documentCacheStore.clearGenerating(notebookId);
|
||||
|
||||
if (pendingReGen.has(notebookId)) {
|
||||
pendingReGen.delete(notebookId);
|
||||
logger.info({ notebookId }, 're-triggering pre-generation for updated sources');
|
||||
runPreGeneration(notebookId);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ notebookId, err: err.message }, 'pre-generation setup failed');
|
||||
documentCacheStore.clearGenerating(notebookId);
|
||||
}
|
||||
}
|
||||
216
server/src/services/preGenerationService.test.js
Normal file
216
server/src/services/preGenerationService.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../stores/notebookStore.js', () => ({
|
||||
getNotebook: vi.fn(),
|
||||
getSources: vi.fn(),
|
||||
getChunksForNotebook: vi.fn(),
|
||||
buildSourceGroups: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/documentCacheStore.js', () => ({
|
||||
isGenerating: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
markGenerating: vi.fn(),
|
||||
clearGenerating: vi.fn(),
|
||||
setCachedDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./documentService.js', () => ({
|
||||
generateStudyGuide: vi.fn(),
|
||||
generateFaq: vi.fn(),
|
||||
generateExecutiveBrief: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { triggerPreGeneration } from './preGenerationService.js';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
import * as documentCacheStore from '../stores/documentCacheStore.js';
|
||||
import {
|
||||
generateStudyGuide,
|
||||
generateFaq,
|
||||
generateExecutiveBrief,
|
||||
} from './documentService.js';
|
||||
|
||||
function setupNotebook(id) {
|
||||
notebookStore.getNotebook.mockReturnValue({ id });
|
||||
notebookStore.getSources.mockReturnValue([{ id: 's1' }, { id: 's2' }]);
|
||||
notebookStore.getChunksForNotebook.mockReturnValue([{ id: 'c1', text: 'hello' }]);
|
||||
notebookStore.buildSourceGroups.mockReturnValue([
|
||||
{ docIndex: 1, name: 'Doc', chunks: [{ text: 'hello' }] },
|
||||
]);
|
||||
}
|
||||
|
||||
function stubGeneratorsOk() {
|
||||
generateStudyGuide.mockResolvedValue({ title: 'guide' });
|
||||
generateFaq.mockResolvedValue({ subject: 'topic' });
|
||||
generateExecutiveBrief.mockResolvedValue({ title: 'brief' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('triggerPreGeneration guards', () => {
|
||||
it('does nothing if the notebook does not exist', () => {
|
||||
notebookStore.getNotebook.mockReturnValue(null);
|
||||
triggerPreGeneration('nb-missing');
|
||||
expect(notebookStore.getSources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if fewer than 2 sources', () => {
|
||||
notebookStore.getNotebook.mockReturnValue({ id: 'nb-few' });
|
||||
notebookStore.getSources.mockReturnValue([{ id: 's1' }]);
|
||||
triggerPreGeneration('nb-few');
|
||||
expect(documentCacheStore.markGenerating).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queuing when already generating', () => {
|
||||
it('invalidates cache and skips runPreGeneration', () => {
|
||||
setupNotebook('nb-q');
|
||||
documentCacheStore.isGenerating.mockReturnValue(true);
|
||||
|
||||
triggerPreGeneration('nb-q');
|
||||
|
||||
expect(documentCacheStore.invalidate).toHaveBeenCalledWith('nb-q');
|
||||
expect(documentCacheStore.markGenerating).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
it('does not run generation immediately', () => {
|
||||
setupNotebook('nb-debounce');
|
||||
documentCacheStore.isGenerating.mockReturnValue(false);
|
||||
stubGeneratorsOk();
|
||||
|
||||
triggerPreGeneration('nb-debounce');
|
||||
|
||||
expect(documentCacheStore.markGenerating).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs generation after the debounce delay', async () => {
|
||||
setupNotebook('nb-delay');
|
||||
documentCacheStore.isGenerating.mockReturnValue(false);
|
||||
stubGeneratorsOk();
|
||||
|
||||
triggerPreGeneration('nb-delay');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-delay');
|
||||
});
|
||||
|
||||
expect(documentCacheStore.markGenerating).toHaveBeenCalledWith('nb-delay');
|
||||
});
|
||||
|
||||
it('resets the timer on repeated calls, running generation only once', async () => {
|
||||
setupNotebook('nb-batch');
|
||||
documentCacheStore.isGenerating.mockReturnValue(false);
|
||||
stubGeneratorsOk();
|
||||
|
||||
triggerPreGeneration('nb-batch');
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
triggerPreGeneration('nb-batch');
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
triggerPreGeneration('nb-batch');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(documentCacheStore.clearGenerating).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(documentCacheStore.markGenerating).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPreGeneration (via triggerPreGeneration)', () => {
|
||||
it('runs all three generators and caches results', async () => {
|
||||
setupNotebook('nb-happy');
|
||||
documentCacheStore.isGenerating.mockReturnValue(false);
|
||||
stubGeneratorsOk();
|
||||
|
||||
triggerPreGeneration('nb-happy');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-happy');
|
||||
});
|
||||
|
||||
expect(generateStudyGuide).toHaveBeenCalled();
|
||||
expect(generateFaq).toHaveBeenCalled();
|
||||
expect(generateExecutiveBrief).toHaveBeenCalled();
|
||||
expect(documentCacheStore.setCachedDocument).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('marks generating before starting and clears it after', async () => {
|
||||
setupNotebook('nb-flag');
|
||||
documentCacheStore.isGenerating.mockReturnValue(false);
|
||||
stubGeneratorsOk();
|
||||
|
||||
triggerPreGeneration('nb-flag');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(documentCacheStore.markGenerating).toHaveBeenCalledWith('nb-flag');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-flag');
|
||||
});
|
||||
});
|
||||
|
||||
it('still clears generating flag when a generator fails', async () => {
|
||||
setupNotebook('nb-partial');
|
||||
documentCacheStore.isGenerating.mockReturnValue(false);
|
||||
generateStudyGuide.mockRejectedValue(new Error('boom'));
|
||||
generateFaq.mockResolvedValue({ subject: 'ok' });
|
||||
generateExecutiveBrief.mockResolvedValue({ title: 'ok' });
|
||||
|
||||
triggerPreGeneration('nb-partial');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-partial');
|
||||
});
|
||||
|
||||
expect(documentCacheStore.setCachedDocument).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-trigger for queued notebooks', () => {
|
||||
it('runs a second generation cycle after the first finishes', async () => {
|
||||
setupNotebook('nb-re');
|
||||
documentCacheStore.isGenerating
|
||||
.mockReturnValueOnce(false) // first trigger → debounced
|
||||
.mockReturnValueOnce(true); // second trigger → queues
|
||||
|
||||
let resolveFirst;
|
||||
generateStudyGuide
|
||||
.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r; }))
|
||||
.mockResolvedValue({ title: 'guide' });
|
||||
generateFaq.mockResolvedValue({ subject: 'topic' });
|
||||
generateExecutiveBrief.mockResolvedValue({ title: 'brief' });
|
||||
|
||||
triggerPreGeneration('nb-re');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
// First run is in-flight, blocked on generateStudyGuide
|
||||
|
||||
triggerPreGeneration('nb-re');
|
||||
// Queued via pendingReGen since isGenerating returns true
|
||||
|
||||
resolveFirst({ title: 'guide' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(documentCacheStore.markGenerating).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(documentCacheStore.clearGenerating).toHaveBeenCalledTimes(2);
|
||||
expect(documentCacheStore.setCachedDocument).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
138
server/src/services/retrievalService.js
Normal file
138
server/src/services/retrievalService.js
Normal file
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
import logger from '../logger.js';
|
||||
import * as vectorStore from '../stores/vectorStore.js';
|
||||
import * as notebookStore from '../stores/notebookStore.js';
|
||||
|
||||
const VOYAGE_API_URL = 'https://api.voyageai.com/v1';
|
||||
const EMBED_MODEL = 'voyage-3';
|
||||
const RERANK_MODEL = 'rerank-2';
|
||||
|
||||
function getApiKey() {
|
||||
const key = process.env.VOYAGE_API_KEY;
|
||||
if (!key) throw new Error('VOYAGE_API_KEY is not set');
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function embedTexts(texts, inputType = 'document') {
|
||||
const res = await fetch(`${VOYAGE_API_URL}/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getApiKey()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: EMBED_MODEL,
|
||||
input: texts,
|
||||
input_type: inputType,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Voyage embed failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data.map((d) => d.embedding);
|
||||
}
|
||||
|
||||
export function storeChunkEmbeddings(chunks, embeddings) {
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
vectorStore.storeVector(chunks[i].id, embeddings[i]);
|
||||
}
|
||||
logger.debug({ count: chunks.length }, 'stored chunk embeddings');
|
||||
}
|
||||
|
||||
export function search(queryEmbedding, notebookId, topK = 10) {
|
||||
const chunks = notebookStore.getChunksForNotebook(notebookId);
|
||||
if (chunks.length === 0) return [];
|
||||
|
||||
const scored = [];
|
||||
for (const chunk of chunks) {
|
||||
const vec = vectorStore.getVector(chunk.id);
|
||||
if (!vec) continue;
|
||||
scored.push({ chunk, score: cosineSimilarity(queryEmbedding, vec) });
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored.slice(0, topK);
|
||||
}
|
||||
|
||||
export async function rerank(query, chunks) {
|
||||
if (chunks.length === 0) return [];
|
||||
|
||||
const res = await fetch(`${VOYAGE_API_URL}/rerank`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getApiKey()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: RERANK_MODEL,
|
||||
query,
|
||||
documents: chunks.map((c) => c.text),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Voyage rerank failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data.map((item) => ({
|
||||
chunk: chunks[item.index],
|
||||
relevanceScore: item.relevance_score,
|
||||
}));
|
||||
}
|
||||
|
||||
// Computes cosine similarity between two vectors `a` and `b`.
|
||||
// Cosine similarity measures how similar two vectors' *directions* are,
|
||||
// ignoring their magnitudes. It returns a value from -1 (opposite) to 1 (identical direction).
|
||||
// Formula: cos(θ) = (a · b) / (||a|| * ||b||)
|
||||
function cosineSimilarity(a, b) {
|
||||
// Accumulator for the dot product (a · b): the sum of element-wise products.
|
||||
// The dot product captures how much the two vectors "agree" — it grows
|
||||
// when corresponding elements point the same way and shrinks when they oppose.
|
||||
let dot = 0;
|
||||
|
||||
// Accumulator for the squared magnitude of vector `a` (sum of a[i]²).
|
||||
// After the loop, Math.sqrt(normA) will give ||a||, the Euclidean length of `a`.
|
||||
// This is needed for the denominator, which normalizes out each vector's magnitude
|
||||
// so the result reflects only directional similarity, not scale.
|
||||
let normA = 0;
|
||||
|
||||
// Same as above, but for vector `b`. Together with normA, these two values
|
||||
// will form the denominator ||a|| * ||b|| that scales the dot product into
|
||||
// the -1..1 cosine similarity range.
|
||||
let normB = 0;
|
||||
|
||||
// Walk through every dimension of the two vectors in lockstep.
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
// Multiply the i-th elements of `a` and `b` and add to the running dot product.
|
||||
// Each term a[i]*b[i] contributes positively when both components share the same
|
||||
// sign (both positive or both negative) and negatively when they differ.
|
||||
dot += a[i] * b[i];
|
||||
|
||||
// Square the i-th element of `a` and accumulate it toward `a`'s squared magnitude.
|
||||
// Squaring ensures every component contributes positively regardless of sign.
|
||||
normA += a[i] * a[i];
|
||||
|
||||
// Same for vector `b` — accumulate toward `b`'s squared magnitude.
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
// Compute the denominator: the product of the two vectors' Euclidean lengths.
|
||||
// Taking the square root of each accumulated sum-of-squares converts them from
|
||||
// squared magnitudes back into actual magnitudes (lengths).
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
|
||||
// If either vector has zero magnitude (all zeros), the denominator is 0 and
|
||||
// division would produce NaN, so we return 0 (no meaningful similarity).
|
||||
// Otherwise, dividing the dot product by the combined magnitudes yields the
|
||||
// cosine of the angle between the vectors — our similarity score.
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
}
|
||||
|
||||
export { cosineSimilarity };
|
||||
34
server/src/services/retrievalService.test.js
Normal file
34
server/src/services/retrievalService.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cosineSimilarity } from './retrievalService.js';
|
||||
|
||||
describe('cosineSimilarity', () => {
|
||||
it('returns 1 for identical vectors', () => {
|
||||
expect(cosineSimilarity([1, 2, 3], [1, 2, 3])).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
it('returns 0 for orthogonal vectors', () => {
|
||||
expect(cosineSimilarity([1, 0, 0], [0, 1, 0])).toBeCloseTo(0.0);
|
||||
});
|
||||
|
||||
it('returns -1 for opposite vectors', () => {
|
||||
expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1.0);
|
||||
});
|
||||
|
||||
it('returns 0 when a vector is all zeros', () => {
|
||||
expect(cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(0);
|
||||
});
|
||||
|
||||
it('handles high-dimensional vectors', () => {
|
||||
const a = Array.from({ length: 1024 }, (_, i) => Math.sin(i));
|
||||
const b = Array.from({ length: 1024 }, (_, i) => Math.cos(i));
|
||||
const sim = cosineSimilarity(a, b);
|
||||
expect(sim).toBeGreaterThanOrEqual(-1);
|
||||
expect(sim).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('is symmetric', () => {
|
||||
const a = [0.5, 0.3, 0.8];
|
||||
const b = [0.1, 0.9, 0.4];
|
||||
expect(cosineSimilarity(a, b)).toBeCloseTo(cosineSimilarity(b, a));
|
||||
});
|
||||
});
|
||||
76
server/src/services/scoringService.js
Normal file
76
server/src/services/scoringService.js
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
import { embedTexts, cosineSimilarity } from './retrievalService.js';
|
||||
import * as vectorStore from '../stores/vectorStore.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
// Calibration bounds for voyage-3 cosine similarity.
|
||||
// Empirically, near-direct-quote sentences top out around 0.68-0.72
|
||||
// raw cosine similarity; unrelated text falls below ~0.35.
|
||||
const SIM_FLOOR = 0.35;
|
||||
const SIM_CEILING = 0.65;
|
||||
const MIN_SENTENCE_LENGTH = 20;
|
||||
|
||||
function splitIntoSentences(text) {
|
||||
const cleaned = text.replace(/\[\d+\]/g, '').trim();
|
||||
const raw = cleaned.split(/(?<=[.!?])\s+/);
|
||||
return raw
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length >= MIN_SENTENCE_LENGTH);
|
||||
}
|
||||
|
||||
function calibrate(rawSim) {
|
||||
const scaled =
|
||||
(rawSim - SIM_FLOOR) / (SIM_CEILING - SIM_FLOOR);
|
||||
return Math.max(0, Math.min(1, scaled));
|
||||
}
|
||||
|
||||
export async function computeGroundedness(
|
||||
answerText,
|
||||
citedChunkIds
|
||||
) {
|
||||
if (!citedChunkIds || citedChunkIds.length === 0) return 0;
|
||||
|
||||
const sentences = splitIntoSentences(answerText);
|
||||
if (sentences.length === 0) return 0;
|
||||
|
||||
const sentenceEmbeddings = await embedTexts(
|
||||
sentences,
|
||||
'document'
|
||||
);
|
||||
|
||||
const chunkVectors = [];
|
||||
for (const chunkId of citedChunkIds) {
|
||||
const vec = vectorStore.getVector(chunkId);
|
||||
if (vec) {
|
||||
chunkVectors.push(vec);
|
||||
} else {
|
||||
logger.debug({ chunkId }, 'no embedding found for cited chunk');
|
||||
}
|
||||
}
|
||||
|
||||
if (chunkVectors.length === 0) return 0;
|
||||
|
||||
let totalCalibrated = 0;
|
||||
|
||||
for (let i = 0; i < sentenceEmbeddings.length; i++) {
|
||||
let bestSim = -1;
|
||||
for (const chunkVec of chunkVectors) {
|
||||
const sim = cosineSimilarity(sentenceEmbeddings[i], chunkVec);
|
||||
if (sim > bestSim) bestSim = sim;
|
||||
}
|
||||
const scored = calibrate(bestSim);
|
||||
logger.debug(
|
||||
{
|
||||
sentence: sentences[i].slice(0, 60),
|
||||
rawSim: bestSim.toFixed(3),
|
||||
calibrated: scored.toFixed(3),
|
||||
},
|
||||
'sentence groundedness'
|
||||
);
|
||||
totalCalibrated += scored;
|
||||
}
|
||||
|
||||
return totalCalibrated / sentenceEmbeddings.length;
|
||||
}
|
||||
|
||||
124
server/src/services/scoringService.test.js
Normal file
124
server/src/services/scoringService.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('./retrievalService.js', () => ({
|
||||
embedTexts: vi.fn(),
|
||||
cosineSimilarity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/vectorStore.js', () => ({
|
||||
getVector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { computeGroundedness } from './scoringService.js';
|
||||
import { embedTexts, cosineSimilarity } from './retrievalService.js';
|
||||
import * as vectorStore from '../stores/vectorStore.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('computeGroundedness', () => {
|
||||
it('returns 0 when citedChunkIds is empty', async () => {
|
||||
expect(await computeGroundedness('Some answer text here.', [])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when citedChunkIds is null/undefined', async () => {
|
||||
expect(await computeGroundedness('Some answer text.', null)).toBe(0);
|
||||
expect(await computeGroundedness('Some answer text.', undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when answer has no sentences long enough', async () => {
|
||||
expect(await computeGroundedness('Short.', ['chunk-1'])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when no chunk vectors are found', async () => {
|
||||
vectorStore.getVector.mockReturnValue(null);
|
||||
const answer = 'This is a sentence that is definitely long enough to pass the filter.';
|
||||
expect(await computeGroundedness(answer, ['chunk-1'])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1.0 when all sentences have similarity at or above the ceiling', async () => {
|
||||
const fakeVec = new Float32Array([1, 0, 0]);
|
||||
vectorStore.getVector.mockReturnValue(fakeVec);
|
||||
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
|
||||
cosineSimilarity.mockReturnValue(0.70);
|
||||
|
||||
const answer = 'This sentence is long enough to be included in the analysis.';
|
||||
const score = await computeGroundedness(answer, ['chunk-1']);
|
||||
expect(score).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 when all sentences have similarity at or below the floor', async () => {
|
||||
const fakeVec = new Float32Array([1, 0, 0]);
|
||||
vectorStore.getVector.mockReturnValue(fakeVec);
|
||||
embedTexts.mockResolvedValue([new Float32Array([0, 1, 0])]);
|
||||
cosineSimilarity.mockReturnValue(0.20);
|
||||
|
||||
const answer = 'This sentence is long enough to be included in the analysis.';
|
||||
const score = await computeGroundedness(answer, ['chunk-1']);
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a value between 0 and 1 for mid-range similarity', async () => {
|
||||
const fakeVec = new Float32Array([1, 0, 0]);
|
||||
vectorStore.getVector.mockReturnValue(fakeVec);
|
||||
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
|
||||
cosineSimilarity.mockReturnValue(0.50);
|
||||
|
||||
const answer = 'This sentence is long enough to be included in the analysis.';
|
||||
const score = await computeGroundedness(answer, ['chunk-1']);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
expect(score).toBeLessThan(1);
|
||||
expect(score).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
it('averages across multiple sentences', async () => {
|
||||
const fakeVec = new Float32Array([1, 0, 0]);
|
||||
vectorStore.getVector.mockReturnValue(fakeVec);
|
||||
embedTexts.mockResolvedValue([
|
||||
new Float32Array([1, 0, 0]),
|
||||
new Float32Array([0, 1, 0]),
|
||||
]);
|
||||
cosineSimilarity
|
||||
.mockReturnValueOnce(0.65)
|
||||
.mockReturnValueOnce(0.35);
|
||||
|
||||
const answer =
|
||||
'This is the first sentence that is long enough. This is the second sentence that is also long enough.';
|
||||
const score = await computeGroundedness(answer, ['chunk-1']);
|
||||
expect(score).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
it('picks the best similarity across multiple chunk vectors', async () => {
|
||||
vectorStore.getVector
|
||||
.mockReturnValueOnce(new Float32Array([1, 0, 0]))
|
||||
.mockReturnValueOnce(new Float32Array([0, 1, 0]));
|
||||
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
|
||||
cosineSimilarity
|
||||
.mockReturnValueOnce(0.40)
|
||||
.mockReturnValueOnce(0.65);
|
||||
|
||||
const answer = 'This sentence is long enough to be included in the analysis.';
|
||||
const score = await computeGroundedness(answer, ['chunk-1', 'chunk-2']);
|
||||
expect(score).toBe(1);
|
||||
});
|
||||
|
||||
it('strips citation markers [1] [2] before splitting sentences', async () => {
|
||||
const fakeVec = new Float32Array([1, 0, 0]);
|
||||
vectorStore.getVector.mockReturnValue(fakeVec);
|
||||
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
|
||||
cosineSimilarity.mockReturnValue(0.50);
|
||||
|
||||
const answer = 'The sky is blue according to science [1]. Water covers most of the earth [2].';
|
||||
await computeGroundedness(answer, ['chunk-1']);
|
||||
|
||||
const embeddedTexts = embedTexts.mock.calls[0][0];
|
||||
for (const text of embeddedTexts) {
|
||||
expect(text).not.toMatch(/\[\d+\]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
275
server/src/services/sourceService.js
Normal file
275
server/src/services/sourceService.js
Normal file
@@ -0,0 +1,275 @@
|
||||
'use strict';
|
||||
|
||||
import pdfParse from 'pdf-parse';
|
||||
import mammoth from 'mammoth';
|
||||
import * as cheerio from 'cheerio';
|
||||
import OpenAI from 'openai';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import dns from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import logger from '../logger.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CHARS_PER_CHUNK = 2000;
|
||||
const OVERLAP_CHARS = 200;
|
||||
|
||||
const AUDIO_MIMETYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/ogg',
|
||||
'audio/flac',
|
||||
'audio/webm',
|
||||
]);
|
||||
|
||||
export function isAudioMimetype(mimetype) {
|
||||
return AUDIO_MIMETYPES.has(mimetype);
|
||||
}
|
||||
|
||||
export async function parseFile(buffer, mimetype, filename) {
|
||||
if (mimetype === 'application/pdf') {
|
||||
const result = await pdfParse(buffer);
|
||||
return result.text;
|
||||
}
|
||||
|
||||
if (
|
||||
mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
filename?.toLowerCase().endsWith('.docx')
|
||||
) {
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
if (isAudioMimetype(mimetype)) {
|
||||
return transcribeAudio(buffer, filename);
|
||||
}
|
||||
|
||||
return buffer.toString('utf-8');
|
||||
}
|
||||
|
||||
const WHISPER_MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
async function transcribeAudio(buffer, filename) {
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) throw new Error('OPENAI_API_KEY is required for audio transcription');
|
||||
|
||||
if (buffer.length > WHISPER_MAX_BYTES) {
|
||||
throw new Error('Audio file exceeds 25 MB Whisper API limit');
|
||||
}
|
||||
|
||||
const client = new OpenAI({ apiKey: key });
|
||||
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() || 'mp3';
|
||||
const file = new File([buffer], `audio.${ext}`, { type: `audio/${ext}` });
|
||||
|
||||
logger.info({ filename, bytes: buffer.length }, 'transcribing audio with Whisper');
|
||||
|
||||
const response = await client.audio.transcriptions.create({
|
||||
model: 'whisper-1',
|
||||
file,
|
||||
});
|
||||
|
||||
return response.text;
|
||||
}
|
||||
|
||||
const BLOCKED_IP_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^::1$/,
|
||||
/^fc00:/i,
|
||||
/^fe80:/i,
|
||||
/^fd/i,
|
||||
];
|
||||
|
||||
function isBlockedIp(ip) {
|
||||
return BLOCKED_IP_RANGES.some((re) => re.test(ip));
|
||||
}
|
||||
|
||||
async function validateUrl(raw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('Only http and https URLs are allowed');
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (isIP(hostname)) {
|
||||
if (isBlockedIp(hostname)) {
|
||||
throw new Error('URLs pointing to private/internal networks are not allowed');
|
||||
}
|
||||
} else {
|
||||
const { address } = await dns.lookup(hostname);
|
||||
if (isBlockedIp(address)) {
|
||||
throw new Error('URLs pointing to private/internal networks are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
export async function parseUrl(url) {
|
||||
const safeUrl = await validateUrl(url);
|
||||
logger.info({ url: safeUrl }, 'fetching URL content');
|
||||
|
||||
const res = await fetch(safeUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; NotebookClone/1.0)',
|
||||
'Accept': 'text/html,application/xhtml+xml,text/plain',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
const body = await res.text();
|
||||
|
||||
if (contentType.includes('text/plain')) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const $ = cheerio.load(body);
|
||||
$('script, style, nav, footer, header, iframe, noscript').remove();
|
||||
|
||||
const article = $('article').length ? $('article') : $('main').length ? $('main') : $('body');
|
||||
const text = article.text().replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Could not extract meaningful text from URL');
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const YT_URL_RE = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
export function isYoutubeUrl(url) {
|
||||
return YT_URL_RE.test(url);
|
||||
}
|
||||
|
||||
export function extractVideoId(url) {
|
||||
const match = url.match(YT_URL_RE);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function parseYoutubeUrl(url) {
|
||||
const videoId = extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Could not extract YouTube video ID from URL');
|
||||
}
|
||||
|
||||
logger.info({ videoId }, 'fetching YouTube subtitles via yt-dlp');
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'yt-'));
|
||||
try {
|
||||
const { stderr } = await execFileAsync('yt-dlp', [
|
||||
'--skip-download',
|
||||
'--write-auto-sub',
|
||||
'--write-sub',
|
||||
'--sub-lang', 'en',
|
||||
'--sub-format', 'json3',
|
||||
'--no-warnings',
|
||||
'-o', join(tmpDir, '%(id)s'),
|
||||
`https://www.youtube.com/watch?v=${videoId}`,
|
||||
], { timeout: 20000 });
|
||||
|
||||
if (stderr) {
|
||||
logger.warn({ videoId, stderr: stderr.slice(0, 500) }, 'yt-dlp stderr output');
|
||||
}
|
||||
|
||||
const files = readdirSync(tmpDir);
|
||||
const subFile = files.find((f) => f.endsWith('.json3'));
|
||||
if (!subFile) {
|
||||
throw new Error('No English subtitles available for this YouTube video');
|
||||
}
|
||||
|
||||
const data = JSON.parse(readFileSync(join(tmpDir, subFile), 'utf-8'));
|
||||
const events = (data.events || []).filter((e) => e.segs);
|
||||
const text = events
|
||||
.map((e) => e.segs.map((s) => s.utf8 || '').join(''))
|
||||
.join(' ')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Subtitles were empty');
|
||||
}
|
||||
|
||||
logger.info({ videoId, textLength: text.length }, 'YouTube subtitles extracted');
|
||||
return text;
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error('YouTube support requires yt-dlp to be installed (brew install yt-dlp)');
|
||||
}
|
||||
if (err.killed) {
|
||||
throw new Error('yt-dlp timed out fetching subtitles');
|
||||
}
|
||||
if (err.message.startsWith('No English') || err.message.startsWith('Subtitles')) {
|
||||
throw err;
|
||||
}
|
||||
const detail = err.stderr?.slice(0, 300) || err.message;
|
||||
throw new Error(`Failed to extract YouTube subtitles: ${detail}`);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function chunkText(text, sourceId) {
|
||||
const cleaned = text.replace(/\r\n/g, '\n').trim();
|
||||
if (!cleaned) return [];
|
||||
|
||||
const chunks = [];
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
|
||||
while (start < cleaned.length) {
|
||||
const end = Math.min(start + CHARS_PER_CHUNK, cleaned.length);
|
||||
let sliceEnd = end;
|
||||
|
||||
if (end < cleaned.length) {
|
||||
const lastNewline = cleaned.lastIndexOf('\n', end);
|
||||
const lastPeriod = cleaned.lastIndexOf('. ', end);
|
||||
const breakAt = Math.max(lastNewline, lastPeriod);
|
||||
if (breakAt > start + CHARS_PER_CHUNK * 0.5) {
|
||||
sliceEnd = breakAt + 1;
|
||||
}
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
id: uuidv4(),
|
||||
text: cleaned.slice(start, sliceEnd),
|
||||
sourceId,
|
||||
index,
|
||||
});
|
||||
|
||||
index++;
|
||||
if (sliceEnd >= cleaned.length) break;
|
||||
start = sliceEnd - OVERLAP_CHARS;
|
||||
if (start < 0) start = 0;
|
||||
if (start >= sliceEnd) break;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
158
server/src/services/sourceService.test.js
Normal file
158
server/src/services/sourceService.test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { chunkText, parseFile, isYoutubeUrl, extractVideoId, parseUrl } from './sourceService.js';
|
||||
|
||||
describe('chunkText', () => {
|
||||
it('returns a single chunk for short text', () => {
|
||||
const chunks = chunkText('Hello world.', 'src-1');
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].text).toBe('Hello world.');
|
||||
expect(chunks[0].sourceId).toBe('src-1');
|
||||
expect(chunks[0].index).toBe(0);
|
||||
expect(chunks[0].id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns an empty array for empty text', () => {
|
||||
expect(chunkText('', 'src-1')).toEqual([]);
|
||||
expect(chunkText(' ', 'src-1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits long text into multiple chunks', () => {
|
||||
const text = 'A'.repeat(5000);
|
||||
const chunks = chunkText(text, 'src-2');
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('assigns sequential indices', () => {
|
||||
const text = 'word '.repeat(2000);
|
||||
const chunks = chunkText(text, 'src-3');
|
||||
chunks.forEach((c, i) => {
|
||||
expect(c.index).toBe(i);
|
||||
});
|
||||
});
|
||||
|
||||
it('generates unique ids per chunk', () => {
|
||||
const text = 'word '.repeat(2000);
|
||||
const chunks = chunkText(text, 'src-4');
|
||||
const ids = new Set(chunks.map((c) => c.id));
|
||||
expect(ids.size).toBe(chunks.length);
|
||||
});
|
||||
|
||||
it('includes overlap between consecutive chunks', () => {
|
||||
const text = 'word '.repeat(2000);
|
||||
const chunks = chunkText(text, 'src-5');
|
||||
if (chunks.length >= 2) {
|
||||
const end0 = chunks[0].text.slice(-100);
|
||||
const start1 = chunks[1].text.slice(0, 100);
|
||||
const hasOverlap = chunks[1].text.includes(end0.slice(-50));
|
||||
expect(hasOverlap).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFile', () => {
|
||||
it('passes through plain text from a buffer', async () => {
|
||||
const buf = Buffer.from('Hello, plain text.');
|
||||
const result = await parseFile(buf, 'text/plain');
|
||||
expect(result).toBe('Hello, plain text.');
|
||||
});
|
||||
|
||||
it('passes through markdown from a buffer', async () => {
|
||||
const buf = Buffer.from('# Heading\n\nSome markdown.');
|
||||
const result = await parseFile(buf, 'text/markdown');
|
||||
expect(result).toBe('# Heading\n\nSome markdown.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isYoutubeUrl', () => {
|
||||
it('recognizes standard watch URLs', () => {
|
||||
expect(isYoutubeUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes short youtu.be URLs', () => {
|
||||
expect(isYoutubeUrl('https://youtu.be/dQw4w9WgXcQ')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes embed URLs', () => {
|
||||
expect(isYoutubeUrl('https://www.youtube.com/embed/dQw4w9WgXcQ')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-YouTube URLs', () => {
|
||||
expect(isYoutubeUrl('https://example.com/watch?v=abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects URLs with wrong video ID length', () => {
|
||||
expect(isYoutubeUrl('https://youtube.com/watch?v=short')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects plain text that is not a URL', () => {
|
||||
expect(isYoutubeUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractVideoId', () => {
|
||||
it('extracts ID from standard watch URL', () => {
|
||||
expect(extractVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('extracts ID from short URL', () => {
|
||||
expect(extractVideoId('https://youtu.be/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('extracts ID from embed URL', () => {
|
||||
expect(extractVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('extracts ID with extra query params', () => {
|
||||
expect(extractVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120')).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('returns null for non-YouTube URL', () => {
|
||||
expect(extractVideoId('https://example.com/page')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(extractVideoId('')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles IDs with hyphens and underscores', () => {
|
||||
expect(extractVideoId('https://youtu.be/Ab-_cd12EfG')).toBe('Ab-_cd12EfG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUrl – validation & SSRF prevention', () => {
|
||||
it('rejects non-URL strings', async () => {
|
||||
await expect(parseUrl('not a url')).rejects.toThrow('Invalid URL');
|
||||
});
|
||||
|
||||
it('rejects ftp:// scheme', async () => {
|
||||
await expect(parseUrl('ftp://example.com/file.txt')).rejects.toThrow('Only http and https');
|
||||
});
|
||||
|
||||
it('rejects file:// scheme', async () => {
|
||||
await expect(parseUrl('file:///etc/passwd')).rejects.toThrow('Only http and https');
|
||||
});
|
||||
|
||||
it('rejects data: scheme', async () => {
|
||||
await expect(parseUrl('data:text/html,<h1>hi</h1>')).rejects.toThrow('Only http and https');
|
||||
});
|
||||
|
||||
it('rejects localhost IP (127.0.0.1)', async () => {
|
||||
await expect(parseUrl('http://127.0.0.1/admin')).rejects.toThrow('private/internal');
|
||||
});
|
||||
|
||||
it('rejects private 10.x range', async () => {
|
||||
await expect(parseUrl('http://10.0.0.1/secrets')).rejects.toThrow('private/internal');
|
||||
});
|
||||
|
||||
it('rejects private 192.168.x range', async () => {
|
||||
await expect(parseUrl('http://192.168.1.1/')).rejects.toThrow('private/internal');
|
||||
});
|
||||
|
||||
it('rejects link-local 169.254.x range', async () => {
|
||||
await expect(parseUrl('http://169.254.169.254/metadata')).rejects.toThrow('private/internal');
|
||||
});
|
||||
|
||||
it('rejects 0.0.0.0', async () => {
|
||||
await expect(parseUrl('http://0.0.0.0/')).rejects.toThrow('private/internal');
|
||||
});
|
||||
});
|
||||
52
server/src/stores/documentCacheStore.js
Normal file
52
server/src/stores/documentCacheStore.js
Normal file
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function buildSourceHash(sources) {
|
||||
return sources
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export function getCachedDocument(notebookId, type) {
|
||||
const entry = cache.get(`${notebookId}:${type}`);
|
||||
if (!entry) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setCachedDocument(notebookId, type, document, sources) {
|
||||
const key = `${notebookId}:${type}`;
|
||||
cache.set(key, {
|
||||
document,
|
||||
sourceHash: buildSourceHash(sources),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isFresh(notebookId, type, currentSources) {
|
||||
const entry = cache.get(`${notebookId}:${type}`);
|
||||
if (!entry) return false;
|
||||
return entry.sourceHash === buildSourceHash(currentSources);
|
||||
}
|
||||
|
||||
export function invalidate(notebookId) {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(`${notebookId}:`)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markGenerating(notebookId) {
|
||||
const key = `${notebookId}:__generating`;
|
||||
cache.set(key, true);
|
||||
}
|
||||
|
||||
export function clearGenerating(notebookId) {
|
||||
cache.delete(`${notebookId}:__generating`);
|
||||
}
|
||||
|
||||
export function isGenerating(notebookId) {
|
||||
return cache.get(`${notebookId}:__generating`) === true;
|
||||
}
|
||||
96
server/src/stores/documentCacheStore.test.js
Normal file
96
server/src/stores/documentCacheStore.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getCachedDocument,
|
||||
setCachedDocument,
|
||||
isFresh,
|
||||
invalidate,
|
||||
markGenerating,
|
||||
isGenerating,
|
||||
clearGenerating,
|
||||
} from './documentCacheStore.js';
|
||||
|
||||
describe('documentCacheStore', () => {
|
||||
|
||||
describe('getCachedDocument / setCachedDocument', () => {
|
||||
it('returns null for an uncached entry', () => {
|
||||
expect(getCachedDocument('miss-1', 'faq')).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a cached document', () => {
|
||||
const doc = { title: 'Guide' };
|
||||
const sources = [{ id: 's1' }, { id: 's2' }];
|
||||
setCachedDocument('rt-1', 'study-guide', doc, sources);
|
||||
|
||||
const entry = getCachedDocument('rt-1', 'study-guide');
|
||||
expect(entry.document).toEqual(doc);
|
||||
expect(entry.sourceHash).toBe('s1|s2');
|
||||
expect(entry.createdAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('overwrites an existing entry for the same key', () => {
|
||||
setCachedDocument('ow-1', 'faq', { v: 1 }, [{ id: 'a' }]);
|
||||
setCachedDocument('ow-1', 'faq', { v: 2 }, [{ id: 'b' }]);
|
||||
expect(getCachedDocument('ow-1', 'faq').document).toEqual({ v: 2 });
|
||||
});
|
||||
|
||||
it('keeps entries for different types separate', () => {
|
||||
setCachedDocument('sep-1', 'faq', { kind: 'faq' }, [{ id: 'x' }]);
|
||||
setCachedDocument('sep-1', 'study-guide', { kind: 'sg' }, [{ id: 'x' }]);
|
||||
expect(getCachedDocument('sep-1', 'faq').document.kind).toBe('faq');
|
||||
expect(getCachedDocument('sep-1', 'study-guide').document.kind).toBe('sg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFresh', () => {
|
||||
it('returns false when nothing is cached', () => {
|
||||
expect(isFresh('fresh-miss', 'faq', [{ id: 'a' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when source IDs match regardless of order', () => {
|
||||
setCachedDocument('fresh-match', 'faq', {}, [{ id: 'b' }, { id: 'a' }]);
|
||||
expect(isFresh('fresh-match', 'faq', [{ id: 'a' }, { id: 'b' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when source IDs differ', () => {
|
||||
setCachedDocument('fresh-diff', 'faq', {}, [{ id: 'a' }]);
|
||||
expect(isFresh('fresh-diff', 'faq', [{ id: 'a' }, { id: 'b' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('removes all entries for a notebook', () => {
|
||||
setCachedDocument('inv-1', 'faq', { a: 1 }, [{ id: 'x' }]);
|
||||
setCachedDocument('inv-1', 'study-guide', { b: 2 }, [{ id: 'x' }]);
|
||||
invalidate('inv-1');
|
||||
expect(getCachedDocument('inv-1', 'faq')).toBeNull();
|
||||
expect(getCachedDocument('inv-1', 'study-guide')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not affect other notebooks', () => {
|
||||
setCachedDocument('inv-a', 'faq', { a: 1 }, [{ id: 'x' }]);
|
||||
setCachedDocument('inv-b', 'faq', { b: 2 }, [{ id: 'x' }]);
|
||||
invalidate('inv-a');
|
||||
expect(getCachedDocument('inv-a', 'faq')).toBeNull();
|
||||
expect(getCachedDocument('inv-b', 'faq').document).toEqual({ b: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('generating flag', () => {
|
||||
it('defaults to not generating', () => {
|
||||
expect(isGenerating('gen-default')).toBe(false);
|
||||
});
|
||||
|
||||
it('can be set and cleared', () => {
|
||||
markGenerating('gen-cycle');
|
||||
expect(isGenerating('gen-cycle')).toBe(true);
|
||||
clearGenerating('gen-cycle');
|
||||
expect(isGenerating('gen-cycle')).toBe(false);
|
||||
});
|
||||
|
||||
it('is cleared when the notebook is invalidated', () => {
|
||||
markGenerating('gen-inv');
|
||||
invalidate('gen-inv');
|
||||
expect(isGenerating('gen-inv')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
server/src/stores/notebookStore.js
Normal file
88
server/src/stores/notebookStore.js
Normal file
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
|
||||
export class NotebookStore {
|
||||
constructor() {
|
||||
this.notebooks = new Map();
|
||||
}
|
||||
|
||||
getAllNotebooks() {
|
||||
return Array.from(this.notebooks.values()).map(({ chunks, ...meta }) => meta);
|
||||
}
|
||||
|
||||
getNotebook(id) {
|
||||
return this.notebooks.get(id) || null;
|
||||
}
|
||||
|
||||
createNotebook(notebook) {
|
||||
const record = { ...notebook, sources: [], chunks: [] };
|
||||
this.notebooks.set(record.id, record);
|
||||
return { id: record.id, name: record.name, createdAt: record.createdAt };
|
||||
}
|
||||
|
||||
deleteNotebook(id) {
|
||||
return this.notebooks.delete(id);
|
||||
}
|
||||
|
||||
addSource(notebookId, source) {
|
||||
const notebook = this.notebooks.get(notebookId);
|
||||
if (!notebook) return null;
|
||||
notebook.sources.push(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
getSources(notebookId) {
|
||||
const notebook = this.notebooks.get(notebookId);
|
||||
if (!notebook) return [];
|
||||
return notebook.sources;
|
||||
}
|
||||
|
||||
addChunksToNotebook(notebookId, chunks) {
|
||||
const notebook = this.notebooks.get(notebookId);
|
||||
if (!notebook) return null;
|
||||
notebook.chunks = notebook.chunks.concat(chunks);
|
||||
return notebook;
|
||||
}
|
||||
|
||||
getChunksForNotebook(notebookId) {
|
||||
const notebook = this.notebooks.get(notebookId);
|
||||
if (!notebook) return [];
|
||||
return notebook.chunks;
|
||||
}
|
||||
|
||||
buildSourceGroups(notebookId, chunks) {
|
||||
const sources = this.getSources(notebookId);
|
||||
const sourceIndexMap = new Map();
|
||||
sources.forEach((src, i) => {
|
||||
sourceIndexMap.set(src.id, i + 1);
|
||||
});
|
||||
|
||||
const groupMap = new Map();
|
||||
for (const chunk of chunks) {
|
||||
const docIndex = sourceIndexMap.get(chunk.sourceId) || 0;
|
||||
if (!groupMap.has(chunk.sourceId)) {
|
||||
const src = sources.find((s) => s.id === chunk.sourceId);
|
||||
groupMap.set(chunk.sourceId, {
|
||||
docIndex,
|
||||
sourceId: chunk.sourceId,
|
||||
name: src?.name || 'Unknown',
|
||||
chunks: [],
|
||||
});
|
||||
}
|
||||
groupMap.get(chunk.sourceId).chunks.push(chunk);
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values()).sort((a, b) => a.docIndex - b.docIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultStore = new NotebookStore();
|
||||
|
||||
export const getAllNotebooks = defaultStore.getAllNotebooks.bind(defaultStore);
|
||||
export const getNotebook = defaultStore.getNotebook.bind(defaultStore);
|
||||
export const createNotebook = defaultStore.createNotebook.bind(defaultStore);
|
||||
export const deleteNotebook = defaultStore.deleteNotebook.bind(defaultStore);
|
||||
export const addSource = defaultStore.addSource.bind(defaultStore);
|
||||
export const getSources = defaultStore.getSources.bind(defaultStore);
|
||||
export const addChunksToNotebook = defaultStore.addChunksToNotebook.bind(defaultStore);
|
||||
export const getChunksForNotebook = defaultStore.getChunksForNotebook.bind(defaultStore);
|
||||
export const buildSourceGroups = defaultStore.buildSourceGroups.bind(defaultStore);
|
||||
65
server/src/stores/notebookStore.test.js
Normal file
65
server/src/stores/notebookStore.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { NotebookStore } from './notebookStore.js';
|
||||
|
||||
describe('notebookStore', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new NotebookStore();
|
||||
});
|
||||
|
||||
it('creates and retrieves a notebook', () => {
|
||||
const nb = store.createNotebook({ id: 'nb-1', name: 'Test', createdAt: '2026-01-01' });
|
||||
expect(nb.id).toBe('nb-1');
|
||||
expect(nb.name).toBe('Test');
|
||||
|
||||
const found = store.getNotebook('nb-1');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('lists all notebooks without chunks', () => {
|
||||
store.createNotebook({ id: 'nb-1', name: 'A', createdAt: '2026-01-01' });
|
||||
store.createNotebook({ id: 'nb-2', name: 'B', createdAt: '2026-01-02' });
|
||||
const all = store.getAllNotebooks();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0]).not.toHaveProperty('chunks');
|
||||
});
|
||||
|
||||
it('deletes a notebook', () => {
|
||||
store.createNotebook({ id: 'nb-1', name: 'A', createdAt: '2026-01-01' });
|
||||
expect(store.deleteNotebook('nb-1')).toBe(true);
|
||||
expect(store.getNotebook('nb-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false when deleting a non-existent notebook', () => {
|
||||
expect(store.deleteNotebook('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('adds and retrieves sources', () => {
|
||||
store.createNotebook({ id: 'nb-1', name: 'A', createdAt: '2026-01-01' });
|
||||
store.addSource('nb-1', { id: 's-1', name: 'doc.pdf' });
|
||||
const sources = store.getSources('nb-1');
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0].name).toBe('doc.pdf');
|
||||
});
|
||||
|
||||
it('returns empty sources for non-existent notebook', () => {
|
||||
expect(store.getSources('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds and retrieves chunks', () => {
|
||||
store.createNotebook({ id: 'nb-1', name: 'A', createdAt: '2026-01-01' });
|
||||
store.addChunksToNotebook('nb-1', [
|
||||
{ id: 'c-1', text: 'chunk one' },
|
||||
{ id: 'c-2', text: 'chunk two' },
|
||||
]);
|
||||
const chunks = store.getChunksForNotebook('nb-1');
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(chunks[0].text).toBe('chunk one');
|
||||
});
|
||||
|
||||
it('returns empty chunks for non-existent notebook', () => {
|
||||
expect(store.getChunksForNotebook('nope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
21
server/src/stores/vectorStore.js
Normal file
21
server/src/stores/vectorStore.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const vectors = new Map();
|
||||
|
||||
export function storeVector(chunkId, embedding) {
|
||||
vectors.set(chunkId, new Float32Array(embedding));
|
||||
}
|
||||
|
||||
export function getVector(chunkId) {
|
||||
return vectors.get(chunkId) || null;
|
||||
}
|
||||
|
||||
export function getAllVectors() {
|
||||
return vectors;
|
||||
}
|
||||
|
||||
export function deleteVectorsForChunks(chunkIds) {
|
||||
for (const id of chunkIds) {
|
||||
vectors.delete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user