Skip to content

Commit fbb56b2

Browse files
Adding extract automation service (#1624)
Signed-off-by: Abhishek Kushwaha <Abhishek.Kushwaha@ibm.com>
1 parent 488e302 commit fbb56b2

14 files changed

Lines changed: 1488 additions & 144 deletions

File tree

‎ai-services/Makefile‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
REGISTRY ?= icr.io/ai-services-private
2-
IMAGE = ai-services
3-
TAG ?= v0.0.449
4-
CONTAINER_BUILDER ?= podman
1+
REGISTRY?=icr.io/ai-services-private
2+
IMAGE=ai-services
3+
TAG?=v0.0.450
4+
CONTAINER_BUILDER?=podman
55
CREDS_ARG := $(if $(and $(REGISTRY_USER),$(REGISTRY_PASSWORD)),--creds="$(REGISTRY_USER):$(REGISTRY_PASSWORD)")
66

77
GO := go

‎ai-services/assets/catalog/openshift/values.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ ui:
99
cpu: "500m"
1010

1111
backend:
12-
image: icr.io/ai-services-cicd/ai-services:v0.0.449
12+
image: icr.io/ai-services-cicd/ai-services:v0.0.450
1313
runtime: "openshift"
1414
adminPasswordHash: ""
1515
# @generate:password length=32, special=false

‎ai-services/assets/catalog/podman/values.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ ui:
44

55
backend:
66
port: ""
7-
image: icr.io/ai-services-cicd/ai-services:v0.0.449
7+
image: icr.io/ai-services-cicd/ai-services:v0.0.450
88
runtime: ""
99
adminPasswordHash: ""
1010
# @generate:password length=32, special=false

‎ai-services/assets/worker/openshift/values.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
worker:
2-
image: icr.io/ai-services-cicd/ai-services:v0.0.449
2+
image: icr.io/ai-services-cicd/ai-services:v0.0.450
33
runtime: "openshift"
44
token: ""
55
gatewayAddr: ""

‎ai-services/assets/worker/podman/values.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
worker:
2-
image: icr.io/ai-services-cicd/ai-services:v0.0.449
2+
image: icr.io/ai-services-cicd/ai-services:v0.0.450
33
runtime: "podman"
44
token: ""
55
gatewayAddr: ""

‎ai-services/tests/e2e/README.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,54 @@ ginkgo -r -tags "$TAGS" \
149149
./tests/e2e -- --app-name=<appname> --template=summarize --runtime=podman
150150
```
151151

152+
## Running Entity Extraction Tests
153+
154+
The Entity Extraction Tests validate the `extract-api` service end-to-end, covering both the synchronous extraction API (`POST /v1/extract`) and the asynchronous jobs API (`POST /v1/extract/jobs`).
155+
156+
The suite manages the full application lifecycle: it runs `catalog configure`, `catalog login`, creates a fresh `extract-app-<runID>`, runs all extraction specs, then deletes the app.
157+
158+
### Prerequisites
159+
160+
- Registry credentials must be set so the suite can pull the extract service image.
161+
- `CATALOG_PASSWORD` must be set — the suite runs `catalog configure` and `catalog login` automatically.
162+
- The Ginkgo CLI version must match the module version (`v2.28.1`). Install with:
163+
164+
```bash
165+
go install github.com/onsi/ginkgo/v2/ginkgo@v2.28.1
166+
export PATH=$PATH:$(go env GOPATH)/bin
167+
```
168+
169+
### Environment variables
170+
171+
```bash
172+
# Required
173+
export CATALOG_PASSWORD="<your-catalog-admin-password>"
174+
export REGISTRY_URL="icr.io"
175+
export REGISTRY_USER_NAME="<your-icr-username>"
176+
export REGISTRY_PASSWORD="<your-icr-api-key>"
177+
178+
# Optional — defaults to true (self-signed certs)
179+
export CATALOG_INSECURE=true
180+
181+
# Optional — override default extract service port (default: 9500)
182+
# export EXTRACT_PORT=9500
183+
```
184+
185+
### Commands to Run Entity Extraction Tests
186+
187+
```bash
188+
# Using make (recommended — build tags applied automatically)
189+
make test TEST_ARGS="--label-filter=extraction-tests --timeout=3h" APP_RUNTIME=podman
190+
191+
# Using ginkgo CLI directly (from ai-services/)
192+
export TAGS="exclude_graphdriver_btrfs containers_image_openpgp remote"
193+
ginkgo -r --procs=1 -tags "$TAGS" \
194+
--label-filter="extraction-tests" \
195+
--timeout=3h --v \
196+
./tests/e2e \
197+
-- -template=extract -runtime=podman
198+
```
199+
152200
## Running Golden Dataset Validation Independently
153201

154202
The RAG Golden Dataset Validation can be executed independently from the full E2E lifecycle. This allows validating an already running RAG application without creating or deleting an application during the test run.

‎ai-services/tests/e2e/cli/runner.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const (
4646
svcDigitizeBackend = "digitize-backend"
4747
svcSimilarityAPI = "similarity-api"
4848
svcSummarizeAPI = "summarize-api"
49+
svcExtractAPI = "extract-api"
4950
)
5051

5152
// ptyWinRows and ptyWinCols define the PTY window size used by runWithPTY.
@@ -497,6 +498,11 @@ func ExtractCatalogSummarizeURL(infoOutput string) string {
497498
return extractURLBySubstring(infoOutput, svcSummarizeAPI)
498499
}
499500

501+
// ExtractExtractURL parses the 'application info' output for the entity extraction service URL.
502+
func ExtractExtractURL(infoOutput string) string {
503+
return extractURLBySubstring(infoOutput, svcExtractAPI)
504+
}
505+
500506
// getOpenShiftRouteURLs fetches OpenShift routes for the given application namespace
501507
// via 'oc get routes' and returns the HTTPS URLs found in the output.
502508
// This is the fallback for OpenShift when 'application info' does not embed URLs.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package common
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"time"
10+
)
11+
12+
// PaginationInfo holds page metadata for list responses.
13+
type PaginationInfo struct {
14+
Total int `json:"total"`
15+
Limit int `json:"limit"`
16+
Offset int `json:"offset"`
17+
}
18+
19+
// HTTPConfigForTimeout returns an HTTP client config with the given timeout and no connection pooling.
20+
func HTTPConfigForTimeout(timeout time.Duration) HTTPClientConfig {
21+
return HTTPClientConfig{
22+
Timeout: timeout,
23+
InsecureSkipVerify: true,
24+
PoolConnections: false,
25+
}
26+
}
27+
28+
// DeleteURL sends a DELETE request and accepts 200 or 204 as success.
29+
func DeleteURL(ctx context.Context, rawURL string, config HTTPClientConfig) error {
30+
_, statusCode, err := DoDELETE(ctx, rawURL, config)
31+
if err != nil {
32+
return err
33+
}
34+
35+
if statusCode != http.StatusOK && statusCode != http.StatusNoContent {
36+
return fmt.Errorf("unexpected status %d", statusCode)
37+
}
38+
39+
return nil
40+
}
41+
42+
// AppendQueryParams returns rawURL with the non-empty params added as an encoded query string.
43+
func AppendQueryParams(rawURL string, params map[string]string) string {
44+
query := url.Values{}
45+
46+
for key, val := range params {
47+
if val != "" {
48+
query.Set(key, val)
49+
}
50+
}
51+
52+
if len(query) == 0 {
53+
return rawURL
54+
}
55+
56+
return rawURL + "?" + query.Encode()
57+
}
58+
59+
// GetJSON sends a GET request, requires HTTP 200 and decodes the JSON body into a T.
60+
func GetJSON[T any](ctx context.Context, rawURL string, config HTTPClientConfig) (*T, error) {
61+
respBody, statusCode, err := DoGET(ctx, rawURL, config)
62+
if err != nil {
63+
return nil, err
64+
}
65+
66+
if statusCode != http.StatusOK {
67+
return nil, fmt.Errorf("unexpected status %d: %s", statusCode, string(respBody))
68+
}
69+
70+
var out T
71+
if err := json.Unmarshal(respBody, &out); err != nil {
72+
return nil, fmt.Errorf("failed to parse response: %w", err)
73+
}
74+
75+
return &out, nil
76+
}
77+
78+
// FieldHasValue reports whether data has key set to a non-nil, non-empty-string value.
79+
func FieldHasValue(data map[string]interface{}, key string) bool {
80+
val, ok := data[key]
81+
if !ok || val == nil {
82+
return false
83+
}
84+
85+
if s, isStr := val.(string); isStr {
86+
return s != ""
87+
}
88+
89+
return true
90+
}
91+
92+
// AllFieldsPresent reports whether every key in expected exists in data.
93+
// When one is missing it returns that key and false.
94+
func AllFieldsPresent(data map[string]interface{}, expected []string) (string, bool) {
95+
for _, key := range expected {
96+
if _, ok := data[key]; !ok {
97+
return key, false
98+
}
99+
}
100+
101+
return "", true
102+
}

‎ai-services/tests/e2e/common/files.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,22 @@ func RemoveDirContents(dir string) error {
8282

8383
return nil
8484
}
85+
86+
// CreateTempFile writes content to a new temporary file whose name matches pattern
87+
// (e.g. "extract-text-*.txt") and returns its path. The caller must remove the file.
88+
func CreateTempFile(pattern, content string) (string, error) {
89+
f, err := os.CreateTemp("", pattern)
90+
if err != nil {
91+
return "", fmt.Errorf("failed to create temp file: %w", err)
92+
}
93+
94+
defer func() { _ = f.Close() }()
95+
96+
if _, err := f.WriteString(content); err != nil {
97+
_ = os.Remove(f.Name())
98+
99+
return "", fmt.Errorf("failed to write temp file: %w", err)
100+
}
101+
102+
return f.Name(), nil
103+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package common
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/project-ai-services/ai-services/internal/pkg/logger"
9+
)
10+
11+
// JobStatus is the lifecycle state of an async job.
12+
type JobStatus string
13+
14+
const (
15+
JobStatusAccepted JobStatus = "accepted"
16+
JobStatusInProgress JobStatus = "in_progress"
17+
JobStatusCompleted JobStatus = "completed"
18+
JobStatusCompletedWithErrors JobStatus = "completed_with_errors"
19+
JobStatusFailed JobStatus = "failed"
20+
)
21+
22+
// JobDocStatus is the lifecycle state of a single document inside a job.
23+
type JobDocStatus string
24+
25+
const (
26+
JobDocStatusPending JobDocStatus = "pending"
27+
JobDocStatusInProgress JobDocStatus = "in_progress"
28+
JobDocStatusCompleted JobDocStatus = "completed"
29+
JobDocStatusFailed JobDocStatus = "failed"
30+
)
31+
32+
// JobPollConfig controls how WaitForJob polls a job.
33+
type JobPollConfig struct {
34+
Timeout time.Duration
35+
Interval time.Duration
36+
LogPrefix string
37+
}
38+
39+
// JobState is the part of a job response that WaitForJob needs to decide whether to keep polling.
40+
type JobState struct {
41+
Status JobStatus
42+
Error *string
43+
}
44+
45+
// classifyJobState reports whether polling is finished and the error, if any, for the given state.
46+
func classifyJobState(state JobState) (bool, error) {
47+
switch state.Status {
48+
case JobStatusCompleted, JobStatusCompletedWithErrors:
49+
return true, nil
50+
case JobStatusFailed:
51+
errMsg := "unknown error"
52+
if state.Error != nil {
53+
errMsg = *state.Error
54+
}
55+
56+
return true, fmt.Errorf("job failed: %s", errMsg)
57+
case JobStatusAccepted, JobStatusInProgress:
58+
return false, nil
59+
default:
60+
return true, fmt.Errorf("unexpected job status: %s", state.Status)
61+
}
62+
}
63+
64+
// WaitForJob polls fetch until the job reaches a terminal state, ctx is cancelled or the timeout elapses.
65+
// stateOf extracts the status and error message from the fetched response.
66+
func WaitForJob[T any](
67+
ctx context.Context,
68+
jobID string,
69+
cfg JobPollConfig,
70+
fetch func(context.Context) (*T, error),
71+
stateOf func(*T) JobState,
72+
) (*T, error) {
73+
deadline := time.Now().Add(cfg.Timeout)
74+
ticker := time.NewTicker(cfg.Interval)
75+
76+
defer ticker.Stop()
77+
78+
for {
79+
select {
80+
case <-ctx.Done():
81+
return nil, ctx.Err()
82+
case <-ticker.C:
83+
if time.Now().After(deadline) {
84+
return nil, fmt.Errorf("timed out waiting for job %s", jobID)
85+
}
86+
87+
job, err := fetch(ctx)
88+
if err != nil {
89+
logger.Warningf("[%s] get job detail failed: %v", cfg.LogPrefix, err)
90+
91+
continue
92+
}
93+
94+
state := stateOf(job)
95+
logger.Infof("[%s] job %s status: %s", cfg.LogPrefix, jobID, state.Status)
96+
97+
if done, jobErr := classifyJobState(state); done {
98+
return job, jobErr
99+
}
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)