Repository navigation
Add document RAG: import, search, streamed chat, review, and embeddable widget - #1
Merged
Merged
Conversation
The 0.4 cosine distance cutoff was tuned too tight for bge-m3: chunks that answer the question sit at 0.45-0.52, so every hit was filtered out and the assistant claimed the knowledge base held nothing. Borderline cases were worse — the filter silently trimmed the source list to one or two entries. The threshold now decides only whether the question is covered at all, by looking at the closest hit; the rest of the retrieved chunks stay in the context and in the sources. Raise the default to 0.6 to match the model.
docker-compose exports APP_ENV, DB_CONNECTION, SESSION_DRIVER, QUEUE_CONNECTION and four more as real environment variables. PHPUnit's <env> does not override those, so the suite ran against the development Postgres, Redis and database sessions: RefreshDatabase wiped the working knowledge base on every run, and the smoke test 500'd on a missing sessions table. Switch the whole <php> block to <server force="true">, which does reach the variables Laravel reads. The smoke test no longer needs RefreshDatabase — the route renders a static view — and now asserts the React root is present.
The /api/documents/import-url endpoint had no way to reach it from the interface. Add a URL field next to the file upload, reusing the optional title input and the isUploading state so the two forms cannot fire at once. Importing queues a chunking job, so the panel now refreshes the document list every three seconds — without it a freshly added page sits at "queued" until a manual reload. Source excerpts render through MarkdownMessage as well, matching how the answer itself is displayed.
The single App component held the chat, the document panel and the review panel at once, so every visit paid for all three and the review questions sat under the chat regardless of what the user came for. Each panel now owns its own URL (/, /documents, /questions) and its own file; App is only the shell that picks one by pathname and renders the nav. All three routes serve the same Blade view, so no router dependency is needed. The document panel also gains a separate title field for the URL import - previously the file title input was shared - and separate busy flags, so one form no longer blocks the other.
Stage 10 of the plan asked for a REST API with OpenAPI docs plus a widget
to embed; only the widget was missing. A host page now gets the assistant
from one line:
<script src="https://grounded.example/widget.js" defer></script>
The widget is plain TypeScript in a shadow root, not the React bundle.
The application bundle weighs ~290 kB and carries global Tailwind styles
that would collide with whatever page hosts it; this one is 4.5 kB and is
isolated from the host's CSS in both directions. It builds separately
(`npm run build:widget`) because an embedded script needs a stable URL
rather than a hashed name from the Vite manifest, and public/widget.js is
committed so the snippet works on a fresh clone.
Running on a foreign origin means the chat endpoint has to answer
cross-origin requests. Laravel's defaults already allow that, so the test
pins the behaviour rather than changing it.
/widget-demo serves a page that is deliberately not the application, so
the widget can be checked where it actually runs.
GET /api/analytics/questions had no interface, so the numbers it reports were only reachable with curl. The endpoint is untouched; this is the view on top of it. /analytics gets its own page and nav tab, matching the split the other panels just went through: summary tiles, the most asked questions with their counts, and the embedding-based groups of questions that mean the same thing. Questions awaiting review are listed in short, with a link to the panel that already resolves them rather than a second copy of it.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings include security, deployment, API, and indexing blockers.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Builds the RAG assistant through stage 10, covering ingestion, vector search, streamed chat, review workflows, analytics, React pages, and an embeddable widget.
Changes:
- Adds document imports, chunking, embeddings, retrieval, and SSE chat.
- Adds feedback, review, analytics, REST/OpenAPI support, and React pages.
- Adds Docker queue services, widget bundling, documentation, and tests.
File summaries
| File | Change / final review note |
|---|---|
vite.widget.config.js |
Configures standalone widget output. |
vite.config.js |
Enables React compilation. |
tsconfig.json |
Adds TypeScript configuration. |
tests/Unit/SimilarChunkSearchTest.php |
Tests vector similarity search. |
tests/Unit/QuestionAnswererTest.php |
Tests answering and distance thresholds. |
tests/Unit/EmbeddingGeneratorTest.php |
Tests Ollama embeddings. |
tests/Unit/ChunkDocumentTest.php |
Tests chunk embedding jobs; the unsaved document setup fails before the mocked path (moderate, 1 vote). |
tests/Feature/WidgetTest.php |
Tests widget publishing and CORS. |
tests/Feature/QuestionReviewTest.php |
Tests review and analytics APIs. |
tests/Feature/QuestionFeedbackTest.php |
Tests answer ratings. |
tests/Feature/ExampleTest.php |
Tests application pages. |
tests/Feature/DocumentImportTest.php |
Tests Markdown, PDF, and web imports. |
tests/Feature/DocumentChunkingTest.php |
Tests overlapping chunks. |
routes/web.php |
Adds dashboard and widget routes. |
routes/api.php |
Adds APIs; CORS preflight handling is missing (critical, 2 votes), and management routes lack authentication/authorization (critical, 3 votes). |
resources/views/widget-demo.blade.php |
Provides the external widget demo page. |
resources/views/welcome.blade.php |
Provides the React shell; unconditional @vite fails without a manifest in clean checkouts (critical, 3 votes). |
resources/js/widget.ts |
Implements the shadow-root widget; interpolating source.title into HTML enables XSS (critical, 3 votes). |
resources/js/types.ts |
Defines frontend data types. |
resources/js/pages/ReviewPage.tsx |
Implements the review workflow. |
resources/js/pages/DocumentsPage.tsx |
Implements document management; PDF files cannot be selected (moderate, 1 vote). |
resources/js/pages/ChatPage.tsx |
Implements streamed chat UI. |
resources/js/pages/AnalyticsPage.tsx |
Implements the analytics UI. |
resources/js/components/MarkdownMessage.tsx |
Renders message formatting. |
resources/js/App.tsx |
Selects pages by pathname. |
resources/js/app.js |
Mounts the React application. |
resources/css/app.css |
Includes TypeScript and TSX sources for Tailwind. |
README.md |
Documents widget usage and setup. |
public/widget.js |
Published bundle retains the unsafe innerHTML interpolation (critical, 2 votes); regenerate it from the safe source. |
phpunit.xml |
Isolates test environment variables. |
package.json |
Adds React, TypeScript, and widget tooling. |
docs/openapi.yaml |
Documents the REST API; /search declares excerpt while results provide content (moderate, 1 vote). |
Dockerfile |
Adds Redis support; the image lacks the DOM extension required by DomCrawler (critical, 1 vote). |
docker-compose.yml |
Adds application and worker services; the 90-second Redis retry window is shorter than the 120-second worker timeout (critical, 1 vote). |
database/migrations/2026_09_18_000002_add_embedding_to_questions_table.php |
Adds question vectors. |
database/migrations/2026_09_18_000001_create_questions_table.php |
Creates question and feedback storage. |
database/migrations/2026_09_17_100000_add_indexing_fields_to_documents_table.php |
Adds indexing status; the store path does not dispatch chunking, leaving text documents unsearchable (critical, 1 vote). |
database/migrations/2026_09_17_000001_add_embedding_to_document_chunks_table.php |
Adds chunk embeddings. |
config/services.php |
Configures Ollama and RAG settings. |
composer.lock |
Locks document parsing dependencies. |
composer.json |
Adds PDF and HTML parsing packages. |
app/Services/SimilarChunkSearch.php |
Performs vector similarity search. |
app/Services/QuestionAnswerer.php |
Generates regular and streamed answers; the SSE path lacks coverage for framing, completion, sources, and no-knowledge behavior (nit, 1 vote). |
app/Services/QuestionAnalytics.php |
Builds analytics; each request synchronously embeds all missing question vectors, risking growth and timeouts (moderate, 3 votes). |
app/Services/EmbeddingGenerator.php |
Calls Ollama embeddings. |
app/Models/Question.php |
Defines question persistence. |
app/Models/Document.php |
Extends document model handling. |
app/Jobs/ChunkDocument.php |
Chunks and embeds documents; records undocumented ready status instead of completed (moderate, 2 votes). |
app/Http/Controllers/QuestionController.php |
Handles feedback and review APIs. |
app/Http/Controllers/DocumentController.php |
URL imports allow SSRF (critical, 3 votes); imports return 200 instead of the required 201 (moderate, 3 votes); search results use content instead of documented excerpt (moderate, 1 vote). |
app/Http/Controllers/ChatController.php |
Exposes chat endpoints. |
app/Console/Commands/WatchDocuments.php |
Watches the inbox; content_hash is absent from the schema and fillable fields, causing import failure (critical, 3 votes). |
.gitignore |
Ignores fixtures and PHPUnit artifacts. |
.env.example |
Adds service settings; documented localhost:11434 is unreachable from the application and worker containers (critical, 3 votes). |
Review details
Suppressed comments (6)
app/Http/Controllers/DocumentController.php:85
- As with
importUrl(), returning the model directly produces 200 OK, while the PDF/Markdown import tests require 201 Created. Wrap this response with an explicit 201 status.
return $document;
app/Http/Controllers/DocumentController.php:28
- The
/searchendpoint returns the rawSimilarChunkSearchrows, whose field iscontent, but the documentedSourceresponse and the shared frontend type requireexcerpt. API consumers therefore receive a different shape from the contract; map this endpoint to the source DTO/alias without breaking thecontentfield still used byQuestionAnswerer.
return $search->search($data['q'], $data['limit'] ?? 5);
app/Services/QuestionAnswerer.php:20
- The new SSE path is not exercised by the tests, while the non-streaming
answer()path is. Without a stream test, token framing, the terminaldoneevent, source serialization, and the no-knowledge branch can regress without detection; add an endpoint/service test that consumes the streamed response.
public function stream(string $question, int $limit = 5): StreamedResponse
{
$questionRecord = Question::create(['question' => $question]);
$chunks = $this->relevantChunks($question, $limit);
return response()->stream(function () use ($question, $questionRecord, $chunks): void {
docs/openapi.yaml:250
SimilarChunkSearchselectscontent,position,document_id,title, anddistance, but the documented/searchresponse referencesSource, which requiresexcerptand does not definecontent. Clients generated from this OpenAPI document will receive a shape that does not satisfy the declared schema; either map the endpoint toSourceor document its actual result type.
type: array
items:
$ref: '#/components/schemas/Source'
resources/js/pages/DocumentsPage.tsx:97
- The page cannot select PDF files even though the importer validates and tests PDF support, so PDF import is inaccessible through the UI. Include
.pdfin the file input's accepted extensions.
tests/Unit/ChunkDocumentTest.php:17 - This test gives the job an unsaved
Document, butChunkDocument::handle()immediately callsupdate()on it. Since the test has noRefreshDatabasesetup and the model lacks required document fields, it will try to persist an invalid/nonexistent row before reaching the mocked embedding path; persist a valid document with the database reset trait or mock the document update.
- Files reviewed: 51/56 changed files
- Comments generated: 14
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| REDIS_HOST=redis | ||
| REDIS_PASSWORD=null | ||
| REDIS_PORT=6379 | ||
| OLLAMA_URL=http://localhost:11434 |
Comment on lines
+4
to
5
| && apt-get install -y --no-install-recommends libpq-dev $PHPIZE_DEPS \ | ||
| && docker-php-ext-install pdo_pgsql \ |
| : file_get_contents($path); | ||
| $hash = hash('sha256', $content); | ||
|
|
||
| if (Document::where('content_hash', $hash)->exists()) { |
Comment on lines
+44
to
+48
| $data = $request->validate([ | ||
| 'url' => ['required', 'url'], | ||
| 'title' => ['sometimes', 'string', 'max:255'], | ||
| ]); | ||
| $html = Http::timeout(20)->get($data['url'])->throw()->body(); |
| public function up(): void | ||
| { | ||
| Schema::table('documents', function (Blueprint $table) { | ||
| $table->string('indexing_status')->default('queued'); |
| Route::get('/documents', [DocumentController::class, 'index']); | ||
| Route::get('/search', [DocumentController::class, 'search']); | ||
| Route::post('/chat', ChatController::class); | ||
| Route::post('/chat/stream', [ChatController::class, 'stream']); |
Comment on lines
8
to
+15
| Route::get('/documents', [DocumentController::class, 'index']); | ||
| Route::get('/search', [DocumentController::class, 'search']); | ||
| Route::post('/chat', ChatController::class); | ||
| Route::post('/chat/stream', [ChatController::class, 'stream']); | ||
| Route::get('/analytics/questions', [QuestionController::class, 'analytics']); | ||
| Route::get('/questions', [QuestionController::class, 'index']); | ||
| Route::post('/questions/{question}/feedback', [QuestionController::class, 'feedback']); | ||
| Route::post('/questions/{question}/resolve', [QuestionController::class, 'resolve']); |
Comment on lines
+48
to
+49
| $this->generateMissingEmbeddings(); | ||
| $questions = DB::table('questions') |
The previous README described the project in the present tense regardless of whether something was implemented, and its setup instructions did not work: a fresh checkout fails three times before it serves a page, because the image has no Composer, git does not track storage/framework, and the Blade view needs a Vite manifest. Every command here was executed against a clean export of HEAD in a separate Compose project, and every curl example is a real request with its real response. Limitations are stated rather than implied: character-based chunking, no vector index, no authentication, a documents:watch command that queries a column no migration creates, and a CI workflow whose test step is commented out. Plans live under Roadmap, marked as not implemented.
Imported documents now queue for chunking, the widget renders source titles as text, and CI installs PHP 8.4 so Composer can resolve the lock file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mainstill has the Docker/Laravel skeleton. This branch is the rest of the application: a local RAG assistant that indexes documents, answers from retrieved chunks, and says so when the knowledge base does not cover the question.What landed
bge-m3, and stores vectors in pgvector.RAG_MAX_DISTANCE=0.6forbge-m3), then an answer fromqwen3:8b. Chat streams over SSE and returns the fragments it used. Below the threshold the model is not called.needs_reviewqueue, and a resolve action that turns a written answer into a new indexed document.docs/openapi.yaml./widget.jsis a small TypeScript bundle in a shadow root, plus/widget-demoas a host page that is not the app itself.Fixes on top of that
phpunit.xmloverrides container env vars through<server force="true">.POST /api/documentsnow queues indexing, same as file and URL import.completed.content_hashcolumn to skip duplicates.retry_afteris 180s so it stays above the worker timeout of 120s./api/*. CI uses PHP 8.4.How to try it
Then open http://localhost:8000 — import a short note, wait until its status is
completed, and ask something it actually contains. Also hit a question it cannot know, rate a bad answer, resolve it from/questions, and load/widget-demo.