Skip to content

Commit f5a0e1c

Browse files
authored
feat(news): school news in the guardian's language, with the AI notice and the original one click away (#837)
* docs(openspec): news-item-translation, school news in the reader's language with the AI notice * feat(news): a news item keeps its AI translations next to the original, and the feed reads in the guardian's language * feat(portal): a news page that shows the AI notice and the original one click away
1 parent 5776a4b commit f5a0e1c

27 files changed

Lines changed: 774 additions & 45 deletions

‎docs/operations/translated-messages.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ Under **Messages** the parent picks a language in **Show messages in**. Each mes
1717

1818
The parent's own messages are never translated. **As written** switches translation off.
1919

20+
School news follows the same choice. Under **News** each item shows its title, then its text in the parent's language with the same notice and the same **Show the original text** button. The title stays as the school wrote it. The **News** page carries the same language picker, so a parent without conversations can pick a language there.
21+
2022
## What you need
2123

2224
Translation runs through Hermiq's message translation feature. It arrives switched off. Your data protection officer acknowledges it under **Settings > Algorithm register** in Hermiq, and an administrator enables it. Until then, and on an instance without Hermiq, every message shows as written. Nothing breaks.
2325

2426
## What is kept
2527

26-
The message keeps its original text. Each translation is stored next to it, once per language, with the language it came from, the model that made it and the notice sentence. A message is translated the first time someone reads it in that language, three messages per page load at most; the rest follow on the next load.
28+
The message, or news item, keeps its original text. Each translation is stored next to it, once per language, with the language it came from, the model that made it and the notice sentence. A message is translated the first time someone reads it in that language, three messages per page load at most; the rest follow on the next load.
2729

2830
## Endpoints
2931

@@ -32,5 +34,6 @@ The message keeps its original text. Each translation is stored next to it, once
3234
| `GET` | `/portal/api/identity/details` | The account holder's own details, including `messageLanguage` |
3335
| `PATCH` | `/portal/api/identity/details` | Set `messageLanguage` to a language tag such as `ar`, or `""` for as written |
3436
| `GET` | `/api/messages/threads/{id}/messages` | The messages; a translated one carries `translation` |
37+
| `GET` | `/api/news/feed` | The published news for this parent; a translated item carries `translation` |
3538

3639
A `translation` holds `targetLanguage`, `text`, `translatedByAi`, `sourceLanguage`, `sourceLanguageDetected`, `model`, `originalRef`, `disclosure` and `disclosureLanguage`.

‎l10n/en.js‎

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎l10n/en.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1519,7 +1519,9 @@
15191519
"Grant this request?": "Grant this request?",
15201520
"{name} can then see the cases of {party}.": "{name} can then see the cases of {party}.",
15211521
"Access granted.": "Access granted.",
1522-
"Request refused.": "Request refused."
1522+
"Request refused.": "Request refused.",
1523+
"Plain text / markdown body, as written. An AI translation for a reader's language is kept in translations, never here.": "Plain text / markdown body, as written. An AI translation for a reader's language is kept in translations, never here.",
1524+
"Server-managed. One entry per language this news item was translated into by AI: the translated text, the source language, the model, a reference to this item and the disclosure sentence. The body stays as written.": "Server-managed. One entry per language this news item was translated into by AI: the translated text, the source language, the model, a reference to this item and the disclosure sentence. The body stays as written."
15231525
},
15241526
"plurals": "",
15251527
"pluralForm": "nplurals=2; plural=(n != 1);"

‎l10n/nl.js‎

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎l10n/nl.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1577,7 +1577,9 @@
15771577
"Grant this request?": "Deze aanvraag toekennen?",
15781578
"{name} can then see the cases of {party}.": "{name} kan dan de zaken van {party} zien.",
15791579
"Access granted.": "Toegang toegekend.",
1580-
"Request refused.": "Aanvraag geweigerd."
1580+
"Request refused.": "Aanvraag geweigerd.",
1581+
"Plain text / markdown body, as written. An AI translation for a reader's language is kept in translations, never here.": "Platte tekst of markdown, zoals geschreven. Een AI-vertaling naar de taal van een lezer staat in de vertalingen, nooit hier.",
1582+
"Server-managed. One entry per language this news item was translated into by AI: the translated text, the source language, the model, a reference to this item and the disclosure sentence. The body stays as written.": "Beheerd door de server. Eén regel per taal waarin dit nieuwsbericht door AI is vertaald: de vertaalde tekst, de brontaal, het model, een verwijzing naar dit bericht en de vermelding dat AI het vertaalde. De tekst zelf blijft zoals geschreven."
15811583
},
15821584
"plurals": "",
15831585
"pluralForm": "nplurals=2; plural=(n != 1);"

‎lib/Controller/NewsGuardianController.php‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
use OCA\Portaliq\AppInfo\Application;
3333
use OCA\Portaliq\Auth\PortalProtected;
34+
use OCA\Portaliq\Service\Identity\PortalSelfServiceService;
3435
use OCA\Portaliq\Service\NewsFeedReader;
3536
use OCA\Portaliq\Service\NewsReadReceiptService;
3637
use OCA\Portaliq\Service\PortalSessionService;
@@ -55,12 +56,17 @@ class NewsGuardianController extends Controller implements PortalProtected {
5556
* @param PortalSessionService $session Resolves the subject from the bearer.
5657
* @param NewsFeedReader $feedReader The guardian-scoped read path.
5758
* @param NewsReadReceiptService $readReceipts Idempotent read-receipt recording.
59+
* @param PortalSelfServiceService|null $selfService Reads the reader's own `messageLanguage`
60+
* (news-item-translation). Nullable and
61+
* trailing so a controller built by hand
62+
* keeps its old shape.
5863
*/
5964
public function __construct(
6065
IRequest $request,
6166
private readonly PortalSessionService $session,
6267
private readonly NewsFeedReader $feedReader,
6368
private readonly NewsReadReceiptService $readReceipts,
69+
private readonly ?PortalSelfServiceService $selfService = null,
6470
) {
6571
parent::__construct(appName: Application::APP_ID, request: $request);
6672
}//end __construct()
@@ -71,6 +77,7 @@ public function __construct(
7177
* @return JSONResponse
7278
*
7379
* @spec openspec/changes/news-and-newsletter-authoring/specs/portaliq-cms/spec.md#requirement-a-newsitem-is-authored-per-school-group-or-child-and-tracks-read-receipts
80+
* @spec openspec/changes/news-item-translation/specs/guardian-message-translation/spec.md#requirement-a-news-item-keeps-its-ai-translations-next-to-the-original
7481
*/
7582
#[PublicPage]
7683
#[NoCSRFRequired]
@@ -81,7 +88,13 @@ public function feed(): JSONResponse {
8188
return new JSONResponse(['error' => 'unauthorized'], Http::STATUS_UNAUTHORIZED);
8289
}
8390

84-
return new JSONResponse($this->feedReader->feedFor(subjectRef: (string)($subject['subjectRef'] ?? '')));
91+
$subjectRef = (string)($subject['subjectRef'] ?? '');
92+
$language = '';
93+
if ($this->selfService !== null && $subjectRef !== '') {
94+
$language = $this->selfService->messageLanguage(subjectRef: $subjectRef);
95+
}
96+
97+
return new JSONResponse($this->feedReader->feedFor(subjectRef: $subjectRef, language: $language));
8598
}//end feed()
8699

87100
/**

‎lib/Service/Messaging/GuardianMessageTranslator.php‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,17 @@ public function __construct(
6363
* @param array<int, array<string, mixed>> $messages The thread's messages, already authorised.
6464
* @param string $readerRef The reader's own subjectRef.
6565
* @param string $language The reader's `messageLanguage`, '' for as written.
66+
* @param string $schema The schema the rows live in and are stored back to:
67+
* `guardianMessage`, or `newsItem` for the news feed.
6668
*
6769
* @return array<int, array<string, mixed>> The same messages, each carrying
6870
* `translation` when one applies.
6971
*
7072
* @spec openspec/changes/translated-message-notice/specs/guardian-message-translation/spec.md#requirement-translation-work-per-request-is-bounded-and-skips-what-needs-none
7173
* @spec openspec/changes/translated-message-notice/specs/guardian-message-translation/spec.md#requirement-the-stored-message-keeps-both-texts-and-the-provenance
74+
* @spec openspec/changes/news-item-translation/specs/guardian-message-translation/spec.md#requirement-a-news-item-keeps-its-ai-translations-next-to-the-original
7275
*/
73-
public function forReader(array $messages, string $readerRef, string $language): array {
76+
public function forReader(array $messages, string $readerRef, string $language, string $schema = self::MESSAGE_SCHEMA): array {
7477
if ($language === '') {
7578
return $messages;
7679
}
@@ -84,7 +87,7 @@ public function forReader(array $messages, string $readerRef, string $language):
8487
$entry = $this->storedEntry(message: $message, language: $language);
8588
if ($entry === null && $budget > 0) {
8689
$budget--;
87-
$entry = $this->translateAndStore(message: $message, language: $language);
90+
$entry = $this->translateAndStore(message: $message, language: $language, schema: $schema);
8891
}
8992

9093
if ($entry !== null && $this->differs(entry: $entry, language: $language) === true) {
@@ -118,12 +121,13 @@ private function storedEntry(array $message, string $language): ?array {
118121
*
119122
* @param array<string, mixed> $message The message.
120123
* @param string $language The reader's language.
124+
* @param string $schema The schema the row is stored back to.
121125
*
122126
* @return array<string, mixed>|null The new entry, or null.
123127
*
124128
* @spec openspec/changes/translated-message-notice/specs/guardian-message-translation/spec.md#requirement-the-stored-message-keeps-both-texts-and-the-provenance
125129
*/
126-
private function translateAndStore(array $message, string $language): ?array {
130+
private function translateAndStore(array $message, string $language, string $schema): ?array {
127131
$id = $this->store->rowId(row: $message);
128132
if ($id === null) {
129133
return null;
@@ -132,7 +136,7 @@ private function translateAndStore(array $message, string $language): ?array {
132136
$entry = $this->client->translate(
133137
text: (string)$message['body'],
134138
targetLanguage: $language,
135-
originalRef: 'portaliq:' . self::MESSAGE_SCHEMA . ':' . $id
139+
originalRef: 'portaliq:' . $schema . ':' . $id
136140
);
137141
if ($entry === null) {
138142
return null;
@@ -143,7 +147,7 @@ private function translateAndStore(array $message, string $language): ?array {
143147
unset($stored['translation'], $stored['@self']);
144148
$stored['translations'] = array_values((array)($message['translations'] ?? []));
145149
$stored['translations'][] = $entry;
146-
$this->store->save(schema: self::MESSAGE_SCHEMA, object: $stored, uuid: $id);
150+
$this->store->save(schema: $schema, object: $stored, uuid: $id);
147151

148152
return $entry;
149153
}//end translateAndStore()

‎lib/Service/NewsFeedReader.php‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
namespace OCA\Portaliq\Service;
3333

34+
use OCA\Portaliq\Service\Messaging\GuardianMessageTranslator;
3435
use Psr\Container\ContainerInterface;
3536
use Psr\Log\LoggerInterface;
3637
use Throwable;
@@ -55,12 +56,17 @@ class NewsFeedReader {
5556
* @param GuardianAudienceFixtureReader $audienceReader Resolves the guardian's own audience.
5657
* @param NewsPhotoConsentGate $photoGate Redacts photos per the consent gate.
5758
* @param LoggerInterface $logger The logger.
59+
* @param GuardianMessageTranslator|null $translator Shows a news body in the reader's
60+
* language (news-item-translation).
61+
* Nullable and trailing so a reader
62+
* built by hand keeps its old shape.
5863
*/
5964
public function __construct(
6065
private readonly ContainerInterface $container,
6166
private readonly GuardianAudienceFixtureReader $audienceReader,
6267
private readonly NewsPhotoConsentGate $photoGate,
6368
private readonly LoggerInterface $logger,
69+
private readonly ?GuardianMessageTranslator $translator = null,
6470
) {
6571
}//end __construct()
6672

@@ -69,13 +75,21 @@ public function __construct(
6975
* audience. An empty/unresolvable audience yields an empty feed, never an
7076
* error (fail-closed empty).
7177
*
78+
* With a `$language` the body of each item is shown in it: a stored AI
79+
* translation is reused, at most three new ones are made per request and
80+
* stored on the item, and the item carries `translation` (decision D24).
81+
* Translation runs on the STORED rows, before the photo consent gate
82+
* redacts the reader's copy, so a stored translation never drops a photo.
83+
*
7284
* @param string $subjectRef The guardian's own subjectRef.
85+
* @param string $language The reader's `messageLanguage`, '' for as written.
7386
*
7487
* @return array<int, array<string, mixed>>
7588
*
7689
* @spec openspec/changes/news-and-newsletter-authoring/specs/portaliq-cms/spec.md#requirement-a-newsitem-is-authored-per-school-group-or-child-and-tracks-read-receipts
90+
* @spec openspec/changes/news-item-translation/specs/guardian-message-translation/spec.md#requirement-a-news-item-keeps-its-ai-translations-next-to-the-original
7791
*/
78-
public function feedFor(string $subjectRef): array {
92+
public function feedFor(string $subjectRef, string $language = ''): array {
7993
$audience = $this->audienceReader->resolveAudience(subjectRef: $subjectRef);
8094
$rows = $this->findAllPublished(schema: 'newsItem');
8195

@@ -94,10 +108,14 @@ public function feedFor(string $subjectRef): array {
94108
continue;
95109
}
96110

97-
$matched[] = $this->photoGate->apply(item: $row);
111+
$matched[] = $row;
98112
}
99113

100-
return $matched;
114+
if ($this->translator !== null && $language !== '') {
115+
$matched = $this->translator->forReader(messages: $matched, readerRef: $subjectRef, language: $language, schema: 'newsItem');
116+
}
117+
118+
return array_map(fn (array $row): array => $this->photoGate->apply(item: $row), $matched);
101119
}//end feedFor()
102120

103121
/**

‎lib/Settings/portaliq_mock_register.json‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"openapi": "3.0.0",
33
"info": {
44
"title": "portaliq demo data",
5-
"version": "1.0.3",
5+
"version": "1.0.4",
66
"description": "Demo data covering every schema this app supplies, offered as the first step of the app's setup walkthrough. Generated from the schemas themselves, so every object satisfies the schema that will validate it."
77
},
88
"x-openregister": {
@@ -2505,7 +2505,8 @@
25052505
"subjectRef": "Voorbeeld Subjectref 1",
25062506
"readAt": "2026-03-01T09:00:00+00:00"
25072507
}
2508-
]
2508+
],
2509+
"translations": []
25092510
},
25102511
{
25112512
"@self": {
@@ -2514,7 +2515,7 @@
25142515
"slug": "newsitem-voorbeeld-title-2-2"
25152516
},
25162517
"title": "Voorbeeld Title 2",
2517-
"body": "Voorbeeld Body 2",
2518+
"body": "De school is vrijdag gesloten vanwege een studiedag.",
25182519
"target": {
25192520
"schoolRef": "Voorbeeld Schoolref 2",
25202521
"groupRefs": [
@@ -2534,6 +2535,20 @@
25342535
"subjectRef": "Voorbeeld Subjectref 2",
25352536
"readAt": "2026-03-02T09:00:00+00:00"
25362537
}
2538+
],
2539+
"translations": [
2540+
{
2541+
"targetLanguage": "ar",
2542+
"text": "المدرسة مغلقة يوم الجمعة بسبب يوم دراسي للمعلمين.",
2543+
"translatedByAi": true,
2544+
"sourceLanguage": "nl",
2545+
"sourceLanguageDetected": true,
2546+
"model": "nextcloud",
2547+
"originalRef": "portaliq:newsItem:demo-2",
2548+
"disclosure": "تُرجم بواسطة الذكاء الاصطناعي. اللغة الأصلية: الهولندية. قد تحتوي هذه الترجمة على أخطاء.",
2549+
"disclosureLanguage": "ar",
2550+
"translatedAt": "2026-09-28T09:00:00+00:00"
2551+
}
25372552
]
25382553
},
25392554
{
@@ -2563,7 +2578,8 @@
25632578
"subjectRef": "Voorbeeld Subjectref 3",
25642579
"readAt": "2026-03-03T09:00:00+00:00"
25652580
}
2566-
]
2581+
],
2582+
"translations": []
25672583
},
25682584
{
25692585
"@self": {

‎lib/Settings/portaliq_register.json‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"info": {
44
"title": "Portaliq Register",
55
"description": "Portaliq — the fleet's shared external portal for clients and suppliers (ADR-046). Holds every portal account, page, session, submission, message, notification, audit entry, traffic record, form and landing-page submission the app reads and writes.",
6-
"version": "0.36.2"
6+
"version": "0.37.0"
77
},
88
"x-openregister": {
99
"type": "application",
@@ -20,7 +20,7 @@
2020
"slug": "portaliq",
2121
"title": "Portaliq Register",
2222
"description": "Portaliq — the fleet's shared external portal for clients and suppliers (ADR-046). Holds every portal account, page, session, submission, message, notification, audit entry and traffic record the app reads and writes.",
23-
"version": "0.36.2",
23+
"version": "0.37.0",
2424
"schemas": [
2525
"exampleDocument",
2626
"glossaryTerm",
@@ -6466,7 +6466,7 @@
64666466
"newsItem": {
64676467
"slug": "newsItem",
64686468
"icon": "FileDocument",
6469-
"version": "0.1.0",
6469+
"version": "0.2.0",
64706470
"title": "News item",
64716471
"authorization": {
64726472
"read": [
@@ -6495,7 +6495,7 @@
64956495
"body": {
64966496
"title": "Body",
64976497
"type": "string",
6498-
"description": "Plain text / markdown body. Not translated by this change (see hermiq's message-translation-delegate).",
6498+
"description": "Plain text / markdown body, as written. An AI translation for a reader's language is kept in translations, never here.",
64996499
"example": "De herfstvakantie loopt van 19 tot 23 oktober."
65006500
},
65016501
"target": {
@@ -6579,6 +6579,15 @@
65796579
}
65806580
},
65816581
"example": []
6582+
},
6583+
"translations": {
6584+
"title": "Translations",
6585+
"type": "array",
6586+
"description": "Server-managed. One entry per language this news item was translated into by AI: the translated text, the source language, the model, a reference to this item and the disclosure sentence. The body stays as written.",
6587+
"items": {
6588+
"type": "object"
6589+
},
6590+
"example": []
65826591
}
65836592
}
65846593
},

0 commit comments

Comments
 (0)