Skip to content

Commit 7f9cc08

Browse files
authored
feat: [Sidecar] Add ability to remove one or all references (#193)
* Add ability to delete references * types * updates * fix comment * fix type * Address PR comments * types * readme
1 parent e652b7a commit 7f9cc08

10 files changed

Lines changed: 238 additions & 19 deletions

File tree

python/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ The application current has three main functions:
3939
2. Ingest Status
4040
3. Rewrite
4141
4. Chat
42+
5. Delete (References)
4243

4344
### Ingest
4445
`Ingest` is called when a Refstudio user uploads Reference documents.
@@ -182,4 +183,34 @@ $ poetry run python main.py --text "What can you tell me about hidden feedback l
182183
"text": "Hidden feedback loops in machine learning refer to situations where two systems indirectly influence each other through the world, leading to changes in behavior that may not be immediately visible. These loops may exist between completely disjoint systems and can make analyzing the effect of proposed changes extremely difficult, adding cost to even simple improvements. It is recommended to look carefully for hidden feedback loops and remove them whenever feasible."
183184
}
184185
]
186+
```
187+
188+
### Delete (References)
189+
`delete` removes a Reference from project storage. It is called when a Reference is deleted from the UI.
190+
191+
It takes a list of `source_filenames` as input and returns a response status. Alternatively, you can use the `--all` parameter to delete all References in storage.
192+
193+
To run `delete`:
194+
195+
```bash
196+
$ poetry run python main.py delete --source_filenames grobid-fails.pdf "Machine Learning at Scale.pdf"
197+
198+
199+
# Example:
200+
$ poetry run python main.py delete --source_filenames grobid-fails.pdf "Machine Learning at Scale.pdf" | jq
201+
202+
# Response:
203+
{
204+
"status": "ok",
205+
"message": ""
206+
}
207+
208+
# Another Example (error):
209+
$ poetry run python main.py delete --source_filenames file-does-not-exist.pdf | jq
210+
211+
# Error Response:
212+
{
213+
"status": "error",
214+
"message": "Unable to delete file-does-not-exist.pdf: not found in storage"
215+
}
185216
```

python/cli.schema.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@
1919
"items": {
2020
"$ref": "#/definitions/ChatResponseChoice"
2121
}
22+
},
23+
"delete": {
24+
"$ref": "#/definitions/DeleteStatusResponse"
2225
}
2326
},
2427
"required": [
2528
"ingest",
2629
"ingest_status",
2730
"rewrite",
28-
"chat"
31+
"chat",
32+
"delete"
2933
],
3034
"definitions": {
3135
"IngestStatus": {
@@ -231,6 +235,22 @@
231235
"index",
232236
"text"
233237
]
238+
},
239+
"DeleteStatusResponse": {
240+
"title": "DeleteStatusResponse",
241+
"type": "object",
242+
"properties": {
243+
"status": {
244+
"$ref": "#/definitions/ResponseStatus"
245+
},
246+
"message": {
247+
"type": "string"
248+
}
249+
},
250+
"required": [
251+
"status",
252+
"message"
253+
]
234254
}
235255
}
236256
}

python/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from sidecar import chat, cli, ingest, rewrite
1+
from sidecar import chat, cli, ingest, rewrite, storage
22

33
if __name__ == '__main__':
44
parser = cli.get_arg_parser()
@@ -19,3 +19,9 @@
1919
if args.command == "chat":
2020
chat.ask_question(args.text)
2121

22+
if args.command == "delete":
23+
if args.all:
24+
storage.delete_references(all_=args.all)
25+
else:
26+
storage.delete_references(source_filenames=args.source_filenames)
27+

python/sidecar/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,18 @@ def get_arg_parser():
4343
"--text",
4444
type=str,
4545
)
46+
47+
delete_parser = subparsers.add_parser(
48+
"delete",
49+
description="Deletes a Reference"
50+
)
51+
delete_parser.add_argument(
52+
"--source_filenames",
53+
nargs="*",
54+
type=str
55+
)
56+
delete_parser.add_argument(
57+
"--all",
58+
action="store_true"
59+
)
4660
return parser

python/sidecar/ingest.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from grobid_client.grobid_client import GrobidClient
1111
from sidecar import shared, typing
1212

13+
from .settings import REFERENCES_JSON_PATH, UPLOADS_DIR
1314
from .shared import HiddenPrints, chunk_text, get_filename_md5
1415
from .storage import JsonStorage
1516
from .typing import Author, IngestResponse, Reference
@@ -35,21 +36,6 @@
3536
GROBID_SERVER_URL = "https://kermitt2-grobid.hf.space"
3637
GROBID_TIMEOUT = 60 * 5
3738

38-
APPDATA_DIR = Path(
39-
os.environ.get('APP_DATA_DIR', '/tmp')
40-
)
41-
PROJECT_NAME = Path(
42-
os.environ.get('PROJECT_NAME', 'project-x')
43-
)
44-
UPLOADS_DIR = Path(
45-
os.path.join(APPDATA_DIR, PROJECT_NAME, 'uploads')
46-
)
47-
REFERENCES_JSON_PATH = Path(
48-
os.path.join(
49-
APPDATA_DIR, PROJECT_NAME, '.storage', 'references.json'
50-
)
51-
)
52-
5339

5440
class PDFIngestion:
5541
def __init__(self, input_dir: Path):

python/sidecar/settings.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import os
2+
from pathlib import Path
3+
4+
from dotenv import load_dotenv
5+
6+
load_dotenv()
7+
8+
9+
APPDATA_DIR = Path(
10+
os.environ.get('APP_DATA_DIR', '/tmp')
11+
)
12+
PROJECT_NAME = Path(
13+
os.environ.get('PROJECT_NAME', 'project-x')
14+
)
15+
UPLOADS_DIR = Path(
16+
os.path.join(APPDATA_DIR, PROJECT_NAME, 'uploads')
17+
)
18+
REFERENCES_JSON_PATH = Path(
19+
os.path.join(
20+
APPDATA_DIR, PROJECT_NAME, '.storage', 'references.json'
21+
)
22+
)

python/sidecar/storage.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import json
22
import logging
33
import os
4+
import sys
45

5-
from sidecar import typing
6+
from sidecar import settings, typing
67

78
logging.root.setLevel(logging.NOTSET)
89

@@ -21,6 +22,12 @@
2122
logger.disabled = os.environ.get("SIDECAR_ENABLE_LOGGING", "false").lower() == "true"
2223

2324

25+
def delete_references(source_filenames: list[str] = [], all_: bool = False):
26+
storage = JsonStorage(settings.REFERENCES_JSON_PATH)
27+
storage.load()
28+
storage.delete(source_filenames=source_filenames, all_=all_)
29+
30+
2431
class JsonStorage:
2532
def __init__(self, filepath: str):
2633
self.filepath = filepath
@@ -52,6 +59,53 @@ def save(self):
5259
with open(self.filepath, 'w') as f:
5360
json.dump(contents, f, indent=2, default=str)
5461

62+
def delete(self, source_filenames: list[str] = [], all_: bool = False):
63+
"""
64+
Delete one or more References from storage.
65+
66+
Parameters
67+
----------
68+
source_filenames : list[str]
69+
List of source filenames to be deleted
70+
all_ : bool, default False
71+
Delete all References from storage
72+
"""
73+
if not source_filenames and not all_:
74+
msg = ("`delete` operation requires one of "
75+
"`source_filenames` or `all_` input parameters")
76+
raise ValueError(msg)
77+
78+
# preprocess references into a dict of source_filename: Reference
79+
# so that we can simply do `del refs[filename]`
80+
refs = {
81+
ref.source_filename: ref for ref in self.references
82+
}
83+
84+
if all_:
85+
source_filenames = list(refs.keys())
86+
87+
for filename in source_filenames:
88+
try:
89+
del refs[filename]
90+
except KeyError:
91+
msg = f"Unable to delete {filename}: not found in storage"
92+
logger.warning(msg)
93+
response = typing.DeleteStatusResponse(
94+
status=typing.ResponseStatus.ERROR,
95+
message=msg
96+
)
97+
sys.stdout.write(response.json())
98+
return
99+
100+
self.references = list(refs.values())
101+
self.save()
102+
103+
response = typing.DeleteStatusResponse(
104+
status=typing.ResponseStatus.OK,
105+
message=""
106+
)
107+
sys.stdout.write(response.json())
108+
55109
def update(self, target: typing.Reference):
56110
"""
57111
Update a Reference in storage with the target reference.

python/sidecar/typing.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class Reference(RefStudioModel):
4343
metadata: dict[str, Any] = {}
4444

4545

46+
class ReferenceDelete(RefStudioModel):
47+
source_filenames: list[str]
48+
49+
4650
class Author(RefStudioModel):
4751
full_name: str
4852
given_name: str | None = None
@@ -71,6 +75,11 @@ class IngestStatusResponse(RefStudioModel):
7175
reference_statuses: list[ReferenceStatus]
7276

7377

78+
class DeleteStatusResponse(RefStudioModel):
79+
status: ResponseStatus
80+
message: str
81+
82+
7483
class RewriteChoice(RefStudioModel):
7584
index: int
7685
text: str
@@ -86,6 +95,7 @@ class CliCommands(RefStudioModel):
8695
ingest_status: IngestStatusResponse
8796
rewrite: list[RewriteChoice]
8897
chat: list[ChatResponseChoice]
98+
delete: DeleteStatusResponse
8999

90100

91101
Reference.update_forward_refs()

python/tests/test_storage.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from pathlib import Path
23

34
from sidecar import storage
@@ -69,4 +70,74 @@ def test_json_storage_update(monkeypatch, tmp_path):
6970
assert jstore.references[1].filename_md5 == "abcdefg123"
7071
assert jstore.references[1].citation_key is None
7172
assert len(jstore.references[1].authors) == 2
72-
assert len(jstore.references[1].chunks) == 6
73+
assert len(jstore.references[1].chunks) == 6
74+
75+
76+
def test_storage_delete_references(monkeypatch, tmp_path, capsys):
77+
fp = Path(__file__).parent.joinpath("fixtures/data/references.json")
78+
jstore = storage.JsonStorage(filepath=fp)
79+
jstore.load()
80+
81+
# mock the filepath before save
82+
# we do not want modify our test reference data for other tests
83+
savepath = tmp_path.joinpath("references.json")
84+
monkeypatch.setattr(jstore, "filepath", savepath)
85+
86+
# -------------
87+
88+
# test: delete with `all_=True`
89+
# expect: all References should be deleted, json response in stdout = OK
90+
jstore.delete(all_=True)
91+
92+
captured = capsys.readouterr()
93+
output = json.loads(captured.out)
94+
95+
assert output['status'] == 'ok'
96+
assert output['message'] == ""
97+
98+
# reload from `savepath` to check that the delete was successful
99+
jstore = storage.JsonStorage(filepath=savepath)
100+
jstore.load()
101+
102+
source_filenames_from_storage = [ref.source_filename for ref in jstore.references]
103+
assert len(source_filenames_from_storage) == 0
104+
105+
# -------------
106+
107+
jstore = storage.JsonStorage(filepath=fp)
108+
jstore.load()
109+
monkeypatch.setattr(jstore, "filepath", savepath)
110+
111+
# test: delete source_filename that is present in references.json
112+
# expect: corresponding Reference is deleted
113+
# json response in stdout should be of status: ok
114+
to_be_deleted = ["some_file.pdf"]
115+
116+
jstore.delete(source_filenames=to_be_deleted)
117+
118+
captured = capsys.readouterr()
119+
output = json.loads(captured.out)
120+
121+
assert output['status'] == 'ok'
122+
assert output['message'] == ""
123+
124+
# reload from `savepath` to check that the delete was successful
125+
jstore = storage.JsonStorage(filepath=savepath)
126+
jstore.load()
127+
128+
source_filenames_from_storage = [ref.source_filename for ref in jstore.references]
129+
assert "some_file.pdf" not in source_filenames_from_storage
130+
131+
# -------------
132+
133+
# test: delete source_filename that is NOT present in references.json
134+
# expect: json response in stdout should be of status: error
135+
to_be_deleted = ["not_in_references_storage.pdf"]
136+
137+
jstore.delete(to_be_deleted)
138+
139+
captured = capsys.readouterr()
140+
output = json.loads(captured.out)
141+
142+
assert output['status'] == 'error'
143+
assert output['message'] != ""

src/api/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface CliCommands {
1919
ingest_status: IngestStatusResponse;
2020
rewrite: RewriteChoice[];
2121
chat: ChatResponseChoice[];
22+
delete: DeleteStatusResponse;
2223
}
2324
export interface IngestResponse {
2425
project_name: string;
@@ -67,3 +68,7 @@ export interface ChatResponseChoice {
6768
index: number;
6869
text: string;
6970
}
71+
export interface DeleteStatusResponse {
72+
status: ResponseStatus;
73+
message: string;
74+
}

0 commit comments

Comments
 (0)