Updated semantic, and added entities/relationship implementation
This commit is contained in:
@@ -38,7 +38,8 @@ src/
|
|||||||
│ └── index.js # Session + episode CRUD and FTS search
|
│ └── index.js # Session + episode CRUD and FTS search
|
||||||
├── semantic/
|
├── semantic/
|
||||||
│ └── index.js # Qdrant collection management, upsert, search, delete
|
│ └── index.js # Qdrant collection management, upsert, search, delete
|
||||||
├── entities/ # Entity + relationship CRUD (upcoming)
|
├── entities/
|
||||||
|
│ └── index.js # Entity + relationship CRUD
|
||||||
└── index.js # Express app + route definitions
|
└── index.js # Express app + route definitions
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,6 +101,15 @@ Qdrant and SQLite work as a pair — neither operates in isolation:
|
|||||||
2. IDs are used to fetch full content from SQLite
|
2. IDs are used to fetch full content from SQLite
|
||||||
3. Results are ranked and assembled into a context package
|
3. Results are ranked and assembled into a context package
|
||||||
|
|
||||||
|
## Entity Layer
|
||||||
|
|
||||||
|
Entities and relationships are stored in SQLite with two key constraints:
|
||||||
|
|
||||||
|
- `UNIQUE(name, type)` on entities — ensures no duplicates; upsert updates existing records
|
||||||
|
- `UNIQUE(from_id, to_id, label)` on relationships — prevents duplicate edges
|
||||||
|
- `ON DELETE CASCADE` on both `from_id` and `to_id` — deleting an entity automatically
|
||||||
|
removes all relationships where it appears on either end
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
@@ -132,7 +142,7 @@ Qdrant and SQLite work as a pair — neither operates in isolation:
|
|||||||
| POST | /episodes | Create a new episode |
|
| POST | /episodes | Create a new episode |
|
||||||
| GET | /episodes/search?q=&limit= | Full-text search across episodes |
|
| GET | /episodes/search?q=&limit= | Full-text search across episodes |
|
||||||
| GET | /episodes/:id | Get episode by ID |
|
| GET | /episodes/:id | Get episode by ID |
|
||||||
| GET | /sessions/:id/episodes?limit=&offset= | Get episodes for a session |
|
| GET | /sessions/:id/episodes?limit=&offset= | Get paginated episodes for a session |
|
||||||
| DELETE | /episodes/:id | Delete an episode |
|
| DELETE | /episodes/:id | Delete an episode |
|
||||||
|
|
||||||
**POST /episodes body:**
|
**POST /episodes body:**
|
||||||
@@ -146,4 +156,58 @@ Qdrant and SQLite work as a pair — neither operates in isolation:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Semantic (Qdrant) and entity REST endpoints will be documented as they are built out.
|
> Note: `/episodes/search` must be defined before `/episodes/:id` in Express to prevent
|
||||||
|
> the word `search` being captured as an ID parameter.
|
||||||
|
|
||||||
|
### Entities
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | /entities | Upsert an entity (creates or updates by name + type) |
|
||||||
|
| GET | /entities/by-type/:type | Get all entities of a given type |
|
||||||
|
| GET | /entities/:id | Get entity by internal ID |
|
||||||
|
| DELETE | /entities/:id | Delete entity (cascades to relationships) |
|
||||||
|
|
||||||
|
**POST /entities body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "NexusAI",
|
||||||
|
"type": "project",
|
||||||
|
"notes": "My AI memory project",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `/entities/by-type/:type` must be defined before `/entities/:id` in Express to
|
||||||
|
> prevent `by-type` being captured as an ID parameter.
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | /relationships | Upsert a relationship between two entities |
|
||||||
|
| GET | /entities/:id/relationships | Get all relationships originating from an entity |
|
||||||
|
| DELETE | /relationships | Delete a specific relationship |
|
||||||
|
|
||||||
|
**POST /relationships body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fromId": 1,
|
||||||
|
"toId": 2,
|
||||||
|
"label": "uses",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DELETE /relationships body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fromId": 1,
|
||||||
|
"toId": 2,
|
||||||
|
"label": "uses"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Relationships are identified by the composite key `(fromId, toId, label)`. Delete uses
|
||||||
|
> the request body rather than URL params as this three-part key is awkward to express
|
||||||
|
> cleanly in a path.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@NexusAI/inference-service",
|
"name": "@nexusai/inference-service",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
111
packages/memory-service/src/entities/index.js
Normal file
111
packages/memory-service/src/entities/index.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
const {getDB} = require('../db');
|
||||||
|
|
||||||
|
/******* Entities ********/
|
||||||
|
|
||||||
|
// Upsert an entity - insert or update if (name, type) already exists
|
||||||
|
function upsertEntity(name, type, notes = null, metadata = null) {
|
||||||
|
const db = getDB();
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO entities (name, type, notes, metadata)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name, type) DO UPDATE SET
|
||||||
|
notes = excluded.notes,
|
||||||
|
metadata = excluded.metadata,
|
||||||
|
updated_at = unixepoch()
|
||||||
|
`);
|
||||||
|
const result = stmt.run(name, type, notes, metadata ? JSON.stringify(metadata) : null);
|
||||||
|
|
||||||
|
return getEntityByNameType(name, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get an entity by its ID
|
||||||
|
function getEntity(id) {
|
||||||
|
const db = getDB();
|
||||||
|
return parseEntity(db.prepare(`SELECT * FROM entities WHERE id = ?`).get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all entities of a given type
|
||||||
|
function getEntitiesByType(type) {
|
||||||
|
const db = getDB();
|
||||||
|
return db.prepare(`SELECT * FROM entities WHERE type = ? ORDER BY name`).all(type).map(parseEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete an entity by ID, cascades to delete relationships involving this entity
|
||||||
|
function deleteEntity(id) {
|
||||||
|
const db = getDB();
|
||||||
|
db.prepare(`DELETE FROM entities WHERE id = ?`).run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/********* Relationships *********/
|
||||||
|
|
||||||
|
// Upsert a relationship, insert or ignore if (from_id, to_id, label) already exists
|
||||||
|
function upsertRelationship(fromId, toId, label, metadata = null){
|
||||||
|
const db = getDB();
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO relationships (from_id, to_id, label, metadata)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(from_id, to_id, label) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = stmt.run(fromId, toId, label, metadata ?JSON.stringify(metadata) : null);
|
||||||
|
return getRelationship(fromId, toId, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve a relationship by (from_id, to_id, label)
|
||||||
|
function getRelationship(fromId, toId, label) {
|
||||||
|
const db = getDB();
|
||||||
|
|
||||||
|
return parseRelationship(
|
||||||
|
db.prepare(`SELECT * FROM relationships WHERE from_id = ? AND to_id = ? AND label = ?`)
|
||||||
|
.get(fromId, toId, label)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieves an entity by its unique (name, type) combination
|
||||||
|
function getEntityByNameType(name, type) {
|
||||||
|
const db = getDB();
|
||||||
|
return parseEntity(db.prepare(`SELECT * FROM entities WHERE name = ? AND type = ?`).get(name, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrive all relationships originating from a given entity
|
||||||
|
function getRelationshipsByEntity(entityId) {
|
||||||
|
const db = getDB();
|
||||||
|
return db.prepare(`SELECT * FROM relationships WHERE from_id = ?`).all(entityId).map(parseRelationship);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a specific relationship by (from_id, to_id, label)
|
||||||
|
function deleteRelationship(fromid, toId, label) {
|
||||||
|
const db = getDB();
|
||||||
|
|
||||||
|
db.prepare(`DELETE FROM relationships WHERE from_id = ? AND to_id = ? AND label = ?`).run(fromId, toId, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*********** Parse Functions ***********/
|
||||||
|
|
||||||
|
function parseEntity(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRelationship(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertEntity,
|
||||||
|
getEntity,
|
||||||
|
getEntitiesByType,
|
||||||
|
getEntityByNameType,
|
||||||
|
deleteEntity,
|
||||||
|
upsertRelationship,
|
||||||
|
getRelationship,
|
||||||
|
getRelationshipsByEntity,
|
||||||
|
deleteRelationship
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ const {getEnv} = require('@nexusai/shared');
|
|||||||
const { getDB } = require('./db');
|
const { getDB } = require('./db');
|
||||||
const episodic = require('./episodic');
|
const episodic = require('./episodic');
|
||||||
const semantic = require('./semantic');
|
const semantic = require('./semantic');
|
||||||
|
const entities = require('./entities');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -94,6 +95,68 @@ app.delete('/episodes/:id', (req, res) => {
|
|||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*********************************** */
|
||||||
|
/********** Entity Routes ********** */
|
||||||
|
/*********************************** */
|
||||||
|
//Upsert an entity, creates or updates if already exists
|
||||||
|
app.post('/entities', (req, res) => {
|
||||||
|
const {name, type, notes, metadata} = req.body;
|
||||||
|
if (!name || !type) {
|
||||||
|
return res.status(400).json({ error: 'name and type are required' });
|
||||||
|
}
|
||||||
|
const entity = entities.upsertEntity(name, type, notes, metadata);
|
||||||
|
res.status(201).json(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get an entity by ID
|
||||||
|
app.get('/entities/:id', (req, res) => {
|
||||||
|
const entity = entities.getEntity(req.params.id);
|
||||||
|
if (!entity) return res.status(404).json({ error: 'Entity not found' });
|
||||||
|
res.json(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all entities of a given type
|
||||||
|
app.get('/entities/by-type/:type', (req, res) => {
|
||||||
|
res.json(entities.getEntitiesByType(req.params.type));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an entity by ID
|
||||||
|
app.delete('/entities/:id', (req, res) => {
|
||||||
|
entities.deleteEntity(req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
/***************************************** */
|
||||||
|
/********** Relationship Routes ********** */
|
||||||
|
/***************************************** */
|
||||||
|
|
||||||
|
// Upsert a relationship between two entities
|
||||||
|
app.post('/relationships', (req, res) => {
|
||||||
|
const {fromId, toId, label, metadata } = req.body;
|
||||||
|
if (!fromId || !toId || !label) {
|
||||||
|
return res.status(400).json({ error: 'fromId, toId and label are required' });
|
||||||
|
}
|
||||||
|
const relationship = entities.upsertRelationship(fromId, toId, label, metadata);
|
||||||
|
res.status(201).json(relationship);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all relationships for a given entity ID
|
||||||
|
app.get('/entities/:id/relationships', (req, res) => {
|
||||||
|
res.json(entities.getRelationshipsByEntity(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a specific relationship
|
||||||
|
app.delete('/relationships', (req, res) => {
|
||||||
|
const {fromId, toId, label} = req.body;
|
||||||
|
if (!fromId || !toId || !label) {
|
||||||
|
return res.status(400).json({ error: 'fromId, toId and label are required' });
|
||||||
|
}
|
||||||
|
entities.deleteRelationship(fromId, toId, label);
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/********************************** */
|
/********************************** */
|
||||||
/********** Start Server ********** */
|
/********** Start Server ********** */
|
||||||
/********************************** */
|
/********************************** */
|
||||||
|
|||||||
Reference in New Issue
Block a user